abstractassistant 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,884 @@
1
+ """
2
+ Main application class for AbstractAssistant.
3
+
4
+ Handles system tray integration, UI coordination, and application lifecycle.
5
+ """
6
+
7
+ import threading
8
+ import time
9
+ from typing import Optional
10
+
11
+ import pystray
12
+ from PIL import Image, ImageDraw
13
+
14
+ from .ui.qt_bubble import QtBubbleManager
15
+ from .core.llm_manager import LLMManager
16
+ from .utils.icon_generator import IconGenerator
17
+ from .config import Config
18
+
19
+
20
+ class EnhancedClickableIcon(pystray.Icon):
21
+ """Custom pystray Icon that handles single/double click differentiation."""
22
+
23
+ def __init__(self, name, image, text=None, single_click_handler=None, double_click_handler=None, debug=False):
24
+ # Store our handlers before calling super().__init__
25
+ self.single_click_handler = single_click_handler
26
+ self.double_click_handler = double_click_handler
27
+ self.debug = debug
28
+ self._stored_menu = None
29
+
30
+ # Click timing management
31
+ self.click_count = 0
32
+ self.click_timer = None
33
+ self.DOUBLE_CLICK_TIMEOUT = 300 # milliseconds
34
+
35
+ if self.debug:
36
+ print(f"🔄 EnhancedClickableIcon created with single_click: {single_click_handler is not None}, double_click: {double_click_handler is not None}")
37
+
38
+ # Create with no menu initially
39
+ super().__init__(name, image, text, menu=None)
40
+
41
+ @property
42
+ def _menu(self):
43
+ """Override _menu property to intercept access and handle click timing."""
44
+ if self.debug:
45
+ print(f"🔍 _menu property accessed! Click count: {self.click_count}")
46
+
47
+ self._handle_click_timing()
48
+ # Return None so no menu is displayed
49
+ return None
50
+
51
+ def _handle_click_timing(self):
52
+ """Handle single/double click timing logic."""
53
+ import threading
54
+
55
+ self.click_count += 1
56
+
57
+ if self.click_count == 1:
58
+ # First click - start timer for single click
59
+ if self.click_timer is not None:
60
+ self.click_timer.cancel()
61
+
62
+ self.click_timer = threading.Timer(
63
+ self.DOUBLE_CLICK_TIMEOUT / 1000.0, # Convert to seconds
64
+ self._execute_single_click
65
+ )
66
+ self.click_timer.start()
67
+
68
+ if self.debug:
69
+ print("🔄 First click detected, starting timer...")
70
+
71
+ elif self.click_count == 2:
72
+ # Second click - cancel timer and execute double click
73
+ if self.click_timer is not None:
74
+ self.click_timer.cancel()
75
+ self.click_timer = None
76
+
77
+ self.click_count = 0 # Reset immediately
78
+ self._execute_double_click()
79
+
80
+ if self.debug:
81
+ print("🔄 Double click detected!")
82
+
83
+ def _execute_single_click(self):
84
+ """Execute single click handler after timeout."""
85
+ self.click_count = 0 # Reset click count
86
+ self.click_timer = None
87
+
88
+ if self.debug:
89
+ print("✅ Single click detected on system tray icon!")
90
+
91
+ if self.single_click_handler:
92
+ try:
93
+ self.single_click_handler()
94
+ except Exception as e:
95
+ print(f"❌ Single click handler error: {e}")
96
+ if self.debug:
97
+ import traceback
98
+ traceback.print_exc()
99
+
100
+ def _execute_double_click(self):
101
+ """Execute double click handler immediately."""
102
+ if self.debug:
103
+ print("✅ Double click detected on system tray icon!")
104
+
105
+ if self.double_click_handler:
106
+ try:
107
+ self.double_click_handler()
108
+ except Exception as e:
109
+ print(f"❌ Double click handler error: {e}")
110
+ if self.debug:
111
+ import traceback
112
+ traceback.print_exc()
113
+
114
+ @_menu.setter
115
+ def _menu(self, value):
116
+ """Allow setting _menu during initialization."""
117
+ if self.debug:
118
+ print(f"🔍 _menu property set to: {value}")
119
+ self._stored_menu = value
120
+
121
+
122
+ class AbstractAssistantApp:
123
+ """Main application class coordinating all components."""
124
+
125
+ def __init__(self, config: Optional[Config] = None, debug: bool = False, listening_mode: str = "wait"):
126
+ """Initialize the AbstractAssistant application.
127
+
128
+ Args:
129
+ config: Configuration object (uses default if None)
130
+ debug: Enable debug mode
131
+ listening_mode: Voice listening mode (none, stop, wait, full)
132
+ """
133
+ self.config = config or Config.default()
134
+ self.debug = debug
135
+ self.listening_mode = listening_mode
136
+
137
+ # Validate configuration
138
+ if not self.config.validate():
139
+ print("Warning: Configuration validation failed, using defaults")
140
+ self.config = Config.default()
141
+
142
+ # Initialize components
143
+ self.icon: Optional[pystray.Icon] = None
144
+ self.bubble_manager: Optional[QtBubbleManager] = None
145
+ self.llm_manager: LLMManager = LLMManager(config=self.config, debug=self.debug)
146
+ self.icon_generator: IconGenerator = IconGenerator(size=self.config.system_tray.icon_size)
147
+
148
+ # Application state
149
+ self.is_running: bool = False
150
+ self.bubble_visible: bool = False
151
+
152
+ if self.debug:
153
+ print(f"AbstractAssistant initialized with config: {self.config.to_dict()}")
154
+
155
+ def create_system_tray_icon(self) -> pystray.Icon:
156
+ """Create and configure the system tray icon."""
157
+ # Generate a modern, clean icon - start with ready state (green, steady)
158
+ icon_image = self.icon_generator.create_app_icon(
159
+ color_scheme="green", # Ready state: steady green
160
+ animated=False # Ready state: no animation
161
+ )
162
+
163
+ if self.debug:
164
+ print("🔄 Creating enhanced system tray icon with single/double click detection")
165
+
166
+ # Use our enhanced ClickableIcon for single/double click handling
167
+ return EnhancedClickableIcon(
168
+ "AbstractAssistant",
169
+ icon_image,
170
+ "AbstractAssistant - AI at your fingertips",
171
+ single_click_handler=self.handle_single_click,
172
+ double_click_handler=self.handle_double_click,
173
+ debug=self.debug
174
+ )
175
+
176
+ def update_icon_status(self, status: str):
177
+ """Update the system tray icon based on application status.
178
+
179
+ Args:
180
+ status: 'ready', 'generating', 'executing', 'thinking'
181
+ """
182
+ if not self.icon:
183
+ return
184
+
185
+ try:
186
+ if status == "ready":
187
+ # Ready: gentle heartbeat green
188
+ self._stop_working_animation()
189
+ self._start_ready_animation()
190
+ elif status in ["generating", "executing", "thinking"]:
191
+ # Working: start continuous animation with cycling colors
192
+ self._start_working_animation()
193
+ return # Don't update icon here, let the timer handle it
194
+ else:
195
+ # Default: steady green
196
+ icon_image = self.icon_generator.create_app_icon(
197
+ color_scheme="green",
198
+ animated=False
199
+ )
200
+
201
+ # Update the icon
202
+ self.icon.icon = icon_image
203
+
204
+ if self.debug:
205
+ print(f"🎨 Updated icon status to: {status}")
206
+
207
+ except Exception as e:
208
+ if self.debug:
209
+ print(f"❌ Error updating icon status: {e}")
210
+
211
+ def _start_working_animation(self):
212
+ """Start the working animation timer for continuous icon updates."""
213
+ try:
214
+ import threading
215
+ import time
216
+
217
+ # Stop any existing timer
218
+ self._stop_working_animation()
219
+
220
+ # Create a heartbeat-like animation with dynamic timing
221
+ def update_working_icon():
222
+ if self.icon:
223
+ try:
224
+ icon_image = self.icon_generator.create_app_icon(
225
+ color_scheme="working",
226
+ animated=True
227
+ )
228
+ self.icon.icon = icon_image
229
+ except Exception as e:
230
+ if self.debug:
231
+ print(f"❌ Error updating working icon: {e}")
232
+
233
+ # Heartbeat-like timer with dynamic intervals
234
+ def heartbeat_timer_loop():
235
+ while hasattr(self, 'working_active') and self.working_active:
236
+ # Fast heartbeat pattern: beat-beat-pause
237
+ update_working_icon()
238
+ time.sleep(0.1) # First beat
239
+ update_working_icon()
240
+ time.sleep(0.1) # Second beat
241
+ update_working_icon()
242
+ time.sleep(0.8) # Longer pause between heartbeats
243
+
244
+ self.working_active = True
245
+ self.working_timer = threading.Thread(target=heartbeat_timer_loop, daemon=True)
246
+ self.working_timer.start()
247
+
248
+ if self.debug:
249
+ print("🎨 Started working animation")
250
+
251
+ except Exception as e:
252
+ if self.debug:
253
+ print(f"❌ Error starting working animation: {e}")
254
+
255
+ def _start_ready_animation(self):
256
+ """Start the gentle ready state heartbeat animation."""
257
+ try:
258
+ import threading
259
+ import time
260
+
261
+ # Stop any existing animations
262
+ self._stop_working_animation()
263
+ self._stop_ready_animation()
264
+
265
+ def update_ready_icon():
266
+ if self.icon:
267
+ try:
268
+ icon_image = self.icon_generator.create_app_icon(
269
+ color_scheme="green",
270
+ animated=True
271
+ )
272
+ self.icon.icon = icon_image
273
+ except Exception as e:
274
+ if self.debug:
275
+ print(f"❌ Error updating ready icon: {e}")
276
+
277
+ # Gentle heartbeat timer - slower, more subtle
278
+ def ready_timer_loop():
279
+ while hasattr(self, 'ready_active') and self.ready_active:
280
+ update_ready_icon()
281
+ time.sleep(0.1) # Update every 100ms for smooth animation
282
+
283
+ self.ready_active = True
284
+ self.ready_timer = threading.Thread(target=ready_timer_loop, daemon=True)
285
+ self.ready_timer.start()
286
+
287
+ if self.debug:
288
+ print("🎨 Started ready heartbeat animation")
289
+
290
+ except Exception as e:
291
+ if self.debug:
292
+ print(f"❌ Error starting ready animation: {e}")
293
+
294
+ def _stop_ready_animation(self):
295
+ """Stop the ready animation."""
296
+ if hasattr(self, 'ready_active'):
297
+ self.ready_active = False
298
+ if self.debug:
299
+ print("🎨 Stopped ready animation")
300
+
301
+ def _stop_working_animation(self):
302
+ """Stop the working animation."""
303
+ if hasattr(self, 'working_active'):
304
+ self.working_active = False
305
+ self._stop_ready_animation() # Also stop ready animation
306
+ if self.debug:
307
+ print("🎨 Stopped working animation")
308
+
309
+
310
+ def show_chat_bubble(self, icon=None, item=None):
311
+ """Show the Qt chat bubble interface."""
312
+ try:
313
+ if self.debug:
314
+ print("🔄 show_chat_bubble called")
315
+
316
+ # Check if TTS is currently speaking and stop it
317
+ if self.bubble_manager and hasattr(self.bubble_manager, 'bubble') and self.bubble_manager.bubble:
318
+ bubble = self.bubble_manager.bubble
319
+ if (hasattr(bubble, 'voice_manager') and bubble.voice_manager and
320
+ bubble.voice_manager.is_speaking()):
321
+ if self.debug:
322
+ print("🔊 TTS is speaking, stopping voice...")
323
+ bubble.voice_manager.stop()
324
+
325
+ # Always show bubble after stopping TTS
326
+ if not self.bubble_visible:
327
+ if self.debug:
328
+ print("🔄 Showing bubble after stopping TTS...")
329
+ self.bubble_manager.show()
330
+ self.bubble_visible = True
331
+ if self.debug:
332
+ print("💬 Qt chat bubble opened after TTS stop")
333
+ return
334
+
335
+ # Show the bubble (should be instant due to preflight initialization)
336
+ if self.bubble_manager:
337
+ if self.debug:
338
+ print("🔄 Showing pre-initialized bubble...")
339
+ self.bubble_manager.show()
340
+ else:
341
+ if self.debug:
342
+ print("⚠️ Bubble manager not pre-initialized, creating now...")
343
+ # Fallback: create bubble manager if preflight failed
344
+ try:
345
+ self.bubble_manager = QtBubbleManager(
346
+ llm_manager=self.llm_manager,
347
+ config=self.config,
348
+ debug=self.debug,
349
+ listening_mode=self.listening_mode
350
+ )
351
+ self.bubble_manager.set_response_callback(self.handle_bubble_response)
352
+ self.bubble_manager.set_error_callback(self.handle_bubble_error)
353
+ self.bubble_manager.set_status_callback(self.update_icon_status)
354
+ self.bubble_manager.set_app_quit_callback(self.quit_application)
355
+ self.bubble_manager.show()
356
+ except Exception as e:
357
+ if self.debug:
358
+ print(f"❌ Failed to create bubble manager: {e}")
359
+ print("💬 AbstractAssistant: Error creating chat bubble")
360
+ return
361
+
362
+ # Mark bubble as visible
363
+ self.bubble_visible = True
364
+
365
+ if self.debug:
366
+ print("💬 Qt chat bubble opened")
367
+
368
+ except Exception as e:
369
+ if self.debug:
370
+ print(f"❌ Error in show_chat_bubble: {e}")
371
+ import traceback
372
+ traceback.print_exc()
373
+ print("💬 AbstractAssistant: Error opening chat bubble")
374
+
375
+ def handle_single_click(self):
376
+ """Handle single click on system tray icon.
377
+
378
+ Behavior:
379
+ - If voice is speaking → pause voice (stay hidden)
380
+ - If voice is paused → resume voice (stay hidden)
381
+ - If voice is idle → show chat bubble
382
+ """
383
+ try:
384
+ if self.debug:
385
+ print("🔄 Single click handler called")
386
+
387
+ # Check if we have voice manager available
388
+ if (self.bubble_manager and
389
+ hasattr(self.bubble_manager, 'bubble') and
390
+ self.bubble_manager.bubble and
391
+ hasattr(self.bubble_manager.bubble, 'voice_manager') and
392
+ self.bubble_manager.bubble.voice_manager):
393
+
394
+ voice_manager = self.bubble_manager.bubble.voice_manager
395
+ voice_state = voice_manager.get_state()
396
+
397
+ if self.debug:
398
+ print(f"🔊 Voice state: {voice_state}")
399
+
400
+ if voice_state == 'speaking':
401
+ # Pause voice, don't show bubble
402
+ success = voice_manager.pause()
403
+ if self.debug:
404
+ print(f"⏸ Voice pause: {'success' if success else 'failed'}")
405
+ return
406
+
407
+ elif voice_state == 'paused':
408
+ # Resume voice, don't show bubble
409
+ success = voice_manager.resume()
410
+ if self.debug:
411
+ print(f"▶ Voice resume: {'success' if success else 'failed'}")
412
+ return
413
+
414
+ # Voice is idle or not available - show chat bubble
415
+ if self.debug:
416
+ print("💬 Voice idle or unavailable, showing chat bubble")
417
+ self.show_chat_bubble()
418
+
419
+ except Exception as e:
420
+ if self.debug:
421
+ print(f"❌ Error in handle_single_click: {e}")
422
+ import traceback
423
+ traceback.print_exc()
424
+ # Fallback - just show chat bubble
425
+ self.show_chat_bubble()
426
+
427
+ def handle_double_click(self):
428
+ """Handle double click on system tray icon.
429
+
430
+ Behavior:
431
+ - If voice is speaking/paused → stop voice + show chat bubble
432
+ - If voice is idle → show chat bubble
433
+ """
434
+ try:
435
+ if self.debug:
436
+ print("🔄 Double click handler called")
437
+
438
+ # Check if we have voice manager available
439
+ if (self.bubble_manager and
440
+ hasattr(self.bubble_manager, 'bubble') and
441
+ self.bubble_manager.bubble and
442
+ hasattr(self.bubble_manager.bubble, 'voice_manager') and
443
+ self.bubble_manager.bubble.voice_manager):
444
+
445
+ voice_manager = self.bubble_manager.bubble.voice_manager
446
+ voice_state = voice_manager.get_state()
447
+
448
+ if self.debug:
449
+ print(f"🔊 Voice state: {voice_state}")
450
+
451
+ if voice_state in ['speaking', 'paused']:
452
+ # Stop voice
453
+ voice_manager.stop()
454
+ if self.debug:
455
+ print("⏹ Voice stopped")
456
+
457
+ # Always show chat bubble on double click
458
+ if self.debug:
459
+ print("💬 Showing chat bubble after double click")
460
+ self.show_chat_bubble()
461
+
462
+ except Exception as e:
463
+ if self.debug:
464
+ print(f"❌ Error in handle_double_click: {e}")
465
+ import traceback
466
+ traceback.print_exc()
467
+ # Fallback - just show chat bubble
468
+ self.show_chat_bubble()
469
+
470
+ def hide_chat_bubble(self):
471
+ """Hide the chat bubble interface."""
472
+ self.bubble_visible = False
473
+ if self.bubble_manager:
474
+ self.bubble_manager.hide()
475
+
476
+ if self.debug:
477
+ print("💬 Chat bubble hidden")
478
+
479
+ def handle_bubble_response(self, response: str):
480
+ """Handle AI response from bubble."""
481
+ if self.debug:
482
+ print(f"🔄 App: handle_bubble_response called with: {response[:100]}...")
483
+
484
+ # Update icon back to ready state (steady green)
485
+ self.update_icon_status("ready")
486
+
487
+ # Show toast notification with response
488
+ self.show_toast_notification(response, "success")
489
+
490
+ # Hide bubble after response
491
+ self.hide_chat_bubble()
492
+
493
+ def handle_bubble_error(self, error: str):
494
+ """Handle error from bubble."""
495
+ # Show error toast notification
496
+ self.show_toast_notification(error, "error")
497
+
498
+ # Hide bubble after error
499
+ self.hide_chat_bubble()
500
+
501
+ def show_toast_notification(self, message: str, type: str = "info"):
502
+ """Show a toast notification."""
503
+ icon = "✅" if type == "success" else "❌" if type == "error" else "ℹ️"
504
+ print(f"{icon} {message}")
505
+
506
+ if self.debug:
507
+ print(f"Toast notification: {type} - {message}")
508
+
509
+ # Show a proper macOS notification
510
+ try:
511
+ import subprocess
512
+ title = "AbstractAssistant"
513
+ subtitle = "AI Response" if type == "success" else "Error"
514
+
515
+ # Truncate message for notification
516
+ display_message = message[:200] + "..." if len(message) > 200 else message
517
+
518
+ # Use osascript to show macOS notification
519
+ script = f'''
520
+ display notification "{display_message}" with title "{title}" subtitle "{subtitle}"
521
+ '''
522
+ subprocess.run(["osascript", "-e", script], check=False)
523
+
524
+ if self.debug:
525
+ print(f"📱 macOS notification shown: {display_message[:50]}...")
526
+
527
+ except Exception as e:
528
+ if self.debug:
529
+ print(f"❌ Failed to show notification: {e}")
530
+ # Fallback - just print
531
+ print(f"💬 {title}: {message}")
532
+
533
+ def set_provider(self, provider: str):
534
+ """Set the active LLM provider."""
535
+ self.llm_manager.set_provider(provider)
536
+
537
+ def update_status(self, status: str):
538
+ """Update application status."""
539
+ # Status is now handled by the web interface
540
+ if self.debug:
541
+ print(f"Status update: {status}")
542
+
543
+ def clear_session(self, icon=None, item=None):
544
+ """Clear the current session."""
545
+ try:
546
+ if self.debug:
547
+ print("🔄 Clearing session...")
548
+
549
+ self.llm_manager.clear_session()
550
+
551
+ if self.debug:
552
+ print("✅ Session cleared")
553
+
554
+ except Exception as e:
555
+ if self.debug:
556
+ print(f"❌ Error clearing session: {e}")
557
+
558
+ def save_session(self, icon=None, item=None):
559
+ """Save the current session to file."""
560
+ try:
561
+ if self.debug:
562
+ print("🔄 Saving session...")
563
+
564
+ # Create sessions directory if it doesn't exist
565
+ import os
566
+ sessions_dir = os.path.join(os.path.expanduser("~"), ".abstractassistant", "sessions")
567
+ os.makedirs(sessions_dir, exist_ok=True)
568
+
569
+ # Generate filename with timestamp
570
+ from datetime import datetime
571
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
572
+ filename = f"session_{timestamp}.json"
573
+ filepath = os.path.join(sessions_dir, filename)
574
+
575
+ # Save session
576
+ success = self.llm_manager.save_session(filepath)
577
+
578
+ if success:
579
+ if self.debug:
580
+ print(f"✅ Session saved to: {filepath}")
581
+ # Show notification
582
+ try:
583
+ from .ui.toast_window import show_toast_notification
584
+ show_toast_notification(f"Session saved to:\n{filename}", debug=self.debug)
585
+ except:
586
+ print(f"💾 Session saved: {filename}")
587
+ else:
588
+ if self.debug:
589
+ print("❌ Failed to save session")
590
+
591
+ except Exception as e:
592
+ if self.debug:
593
+ print(f"❌ Error saving session: {e}")
594
+
595
+ def load_session(self, icon=None, item=None):
596
+ """Load a session from file."""
597
+ try:
598
+ if self.debug:
599
+ print("🔄 Loading session...")
600
+
601
+ # Get sessions directory
602
+ import os
603
+ sessions_dir = os.path.join(os.path.expanduser("~"), ".abstractassistant", "sessions")
604
+
605
+ if not os.path.exists(sessions_dir):
606
+ if self.debug:
607
+ print("❌ No sessions directory found")
608
+ return
609
+
610
+ # Get list of session files
611
+ session_files = [f for f in os.listdir(sessions_dir) if f.endswith('.json')]
612
+
613
+ if not session_files:
614
+ if self.debug:
615
+ print("❌ No session files found")
616
+ try:
617
+ from .ui.toast_window import show_toast_notification
618
+ show_toast_notification("No saved sessions found", debug=self.debug)
619
+ except:
620
+ print("📂 No saved sessions found")
621
+ return
622
+
623
+ # For now, load the most recent session
624
+ # TODO: Add proper file picker dialog
625
+ session_files.sort(reverse=True) # Most recent first
626
+ latest_session = session_files[0]
627
+ filepath = os.path.join(sessions_dir, latest_session)
628
+
629
+ # Load session
630
+ success = self.llm_manager.load_session(filepath)
631
+
632
+ if success:
633
+ if self.debug:
634
+ print(f"✅ Session loaded from: {filepath}")
635
+ # Show notification
636
+ try:
637
+ from .ui.toast_window import show_toast_notification
638
+ show_toast_notification(f"Session loaded:\n{latest_session}", debug=self.debug)
639
+ except:
640
+ print(f"📂 Session loaded: {latest_session}")
641
+ else:
642
+ if self.debug:
643
+ print("❌ Failed to load session")
644
+
645
+ except Exception as e:
646
+ if self.debug:
647
+ print(f"❌ Error loading session: {e}")
648
+
649
+ def _preflight_initialization(self):
650
+ """Pre-initialize components for instant bubble display on first click."""
651
+ if self.debug:
652
+ print("🚀 Starting preflight initialization...")
653
+
654
+ try:
655
+ # Pre-create bubble manager (this is the main bottleneck)
656
+ if self.bubble_manager is None:
657
+ if self.debug:
658
+ print("🔄 Pre-creating bubble manager...")
659
+
660
+ self.bubble_manager = QtBubbleManager(
661
+ llm_manager=self.llm_manager,
662
+ config=self.config,
663
+ debug=self.debug,
664
+ listening_mode=self.listening_mode
665
+ )
666
+
667
+ # Set up callbacks
668
+ self.bubble_manager.set_response_callback(self.handle_bubble_response)
669
+ self.bubble_manager.set_error_callback(self.handle_bubble_error)
670
+ self.bubble_manager.set_status_callback(self.update_icon_status)
671
+ self.bubble_manager.set_app_quit_callback(self.quit_application)
672
+
673
+ if self.debug:
674
+ print("✅ Bubble manager pre-created successfully")
675
+
676
+ # Pre-initialize the bubble itself (this loads UI components, TTS/STT, etc.)
677
+ if self.debug:
678
+ print("🔄 Pre-initializing chat bubble...")
679
+
680
+ # This creates the bubble without showing it
681
+ self.bubble_manager._prepare_bubble()
682
+
683
+ if self.debug:
684
+ print("✅ Preflight initialization completed - bubble ready for instant display")
685
+
686
+ except Exception as e:
687
+ if self.debug:
688
+ print(f"⚠️ Preflight initialization failed: {e}")
689
+ print(" First click will still work but with delay")
690
+
691
+ def quit_application(self, icon=None, item=None):
692
+ """Quit the application gracefully."""
693
+ self.is_running = False
694
+ if self.icon:
695
+ self.icon.stop()
696
+
697
+ # Clean up bubble manager
698
+ if self.bubble_manager:
699
+ try:
700
+ self.bubble_manager.destroy()
701
+ except Exception as e:
702
+ if self.debug:
703
+ print(f"Error destroying bubble manager: {e}")
704
+
705
+ def run(self):
706
+ """Start the application using Qt event loop for proper threading."""
707
+ self.is_running = True
708
+
709
+ try:
710
+ # Import Qt here to avoid conflicts
711
+ from PyQt5.QtWidgets import QApplication, QSystemTrayIcon
712
+ from PyQt5.QtCore import QTimer
713
+ from PyQt5.QtGui import QIcon
714
+ import sys
715
+
716
+ # Create Qt application in main thread
717
+ if not QApplication.instance():
718
+ self.qt_app = QApplication(sys.argv)
719
+ else:
720
+ self.qt_app = QApplication.instance()
721
+
722
+ # Check if system tray is available
723
+ if not QSystemTrayIcon.isSystemTrayAvailable():
724
+ print("❌ System tray is not available on this system")
725
+ return
726
+
727
+ # Create Qt-based system tray icon
728
+ self.qt_icon = self._create_qt_system_tray_icon()
729
+
730
+ # Preflight initialization: Pre-load bubble manager for instant display
731
+ self._preflight_initialization()
732
+
733
+ print("AbstractAssistant started. Check your menu bar!")
734
+ print("Click the icon to open the chat interface.")
735
+
736
+ # Run Qt event loop (this blocks until quit)
737
+ self.qt_app.exec_()
738
+
739
+ except ImportError:
740
+ print("❌ PyQt5 not available. Falling back to pystray...")
741
+ # Fallback to original pystray implementation
742
+ self.icon = self.create_system_tray_icon()
743
+ self.icon.run()
744
+
745
+ def _create_qt_system_tray_icon(self):
746
+ """Create Qt-based system tray icon with proper click detection."""
747
+ from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, QAction
748
+ from PyQt5.QtCore import QTimer
749
+ from PyQt5.QtGui import QIcon, QPixmap
750
+ from PIL import Image
751
+ import io
752
+
753
+ # Generate icon using our icon generator
754
+ icon_image = self.icon_generator.create_app_icon(
755
+ color_scheme="green", # Ready state: steady green
756
+ animated=False # Ready state: no animation
757
+ )
758
+
759
+ # Convert PIL image to QPixmap
760
+ img_buffer = io.BytesIO()
761
+ icon_image.save(img_buffer, format='PNG')
762
+ img_buffer.seek(0)
763
+
764
+ pixmap = QPixmap()
765
+ pixmap.loadFromData(img_buffer.getvalue())
766
+ qt_icon = QIcon(pixmap)
767
+
768
+ # Create system tray icon
769
+ tray_icon = QSystemTrayIcon(qt_icon)
770
+ tray_icon.setToolTip("AbstractAssistant - AI at your fingertips")
771
+
772
+ # Click detection variables
773
+ self.click_timer = QTimer()
774
+ self.click_timer.setSingleShot(True)
775
+ self.click_timer.timeout.connect(self._qt_handle_single_click)
776
+ self.pending_single_click = False
777
+ self.DOUBLE_CLICK_TIMEOUT = 200 # milliseconds (short period to detect double click)
778
+
779
+ # Connect click signal
780
+ tray_icon.activated.connect(self._qt_on_tray_activated)
781
+
782
+ # Create context menu (right-click)
783
+ context_menu = QMenu()
784
+
785
+ show_action = QAction("Show Chat", None)
786
+ show_action.triggered.connect(self.show_chat_bubble)
787
+ context_menu.addAction(show_action)
788
+
789
+ context_menu.addSeparator()
790
+
791
+ quit_action = QAction("Quit", None)
792
+ quit_action.triggered.connect(self._qt_quit_application)
793
+ context_menu.addAction(quit_action)
794
+
795
+ tray_icon.setContextMenu(context_menu)
796
+
797
+ # Show the tray icon
798
+ tray_icon.show()
799
+
800
+ if self.debug:
801
+ print("✅ Qt-based system tray icon created successfully")
802
+
803
+ return tray_icon
804
+
805
+ def _qt_on_tray_activated(self, reason):
806
+ """Handle Qt system tray activation (clicks) with proper delay-based detection.
807
+
808
+ Logic:
809
+ - Single click: reason == 3 only
810
+ - Double click: reason == 3 followed quickly by reason == 2
811
+
812
+ Strategy:
813
+ - When reason == 3: Wait 200ms to see if reason == 2 follows
814
+ - If no reason == 2 within 200ms: Execute single click
815
+ - If reason == 2 arrives within 200ms: Execute ONLY double click
816
+ """
817
+ if self.debug:
818
+ print(f"🖱️ Click detected - reason: {reason}")
819
+
820
+ if reason == 3: # Single click (or first part of double click)
821
+ if self.pending_single_click:
822
+ # Already have a pending single click, ignore this one
823
+ if self.debug:
824
+ print("⚠️ Ignoring additional reason=3 (already pending)")
825
+ return
826
+
827
+ # Mark that we have a pending single click
828
+ self.pending_single_click = True
829
+
830
+ # Start timer to wait for possible reason=2 (double click confirmation)
831
+ self.click_timer.start(self.DOUBLE_CLICK_TIMEOUT)
832
+
833
+ if self.debug:
834
+ print(f"🔄 Qt: reason=3 detected, waiting {self.DOUBLE_CLICK_TIMEOUT}ms for possible reason=2...")
835
+
836
+ elif reason == 2: # Double click confirmation
837
+ if self.pending_single_click and self.click_timer.isActive():
838
+ # We have a pending single click and timer is still running
839
+ # This means reason=2 arrived within the timeout period
840
+
841
+ # Cancel the pending single click
842
+ self.click_timer.stop()
843
+ self.pending_single_click = False
844
+
845
+ if self.debug:
846
+ print("✅ Qt: reason=2 detected - cancelling single click, executing double click!")
847
+
848
+ # Execute ONLY the double click
849
+ self._qt_handle_double_click()
850
+ else:
851
+ # Unexpected reason=2 without pending single click
852
+ if self.debug:
853
+ print("⚠️ Unexpected reason=2 without pending single click")
854
+
855
+ # Execute double click anyway (fallback)
856
+ self._qt_handle_double_click()
857
+
858
+ def _qt_handle_single_click(self):
859
+ """Handle single click after timeout (no reason=2 detected) in Qt main thread."""
860
+ # Clear the pending flag
861
+ self.pending_single_click = False
862
+
863
+ if self.debug:
864
+ print("✅ Qt: Single click confirmed (no reason=2 within 200ms) - executing action!")
865
+
866
+ # This runs in Qt main thread, so it's safe to create Qt widgets
867
+ # Execute single click action (pause/resume voice or show bubble)
868
+ self.handle_single_click()
869
+
870
+ def _qt_handle_double_click(self):
871
+ """Handle double click immediately in Qt main thread."""
872
+ if self.debug:
873
+ print("✅ Qt: Double click detected!")
874
+
875
+ # This runs in Qt main thread, so it's safe to create Qt widgets
876
+ self.handle_double_click()
877
+
878
+ def _qt_quit_application(self):
879
+ """Quit the Qt application."""
880
+ if self.debug:
881
+ print("🔄 Qt: Quit requested")
882
+
883
+ if hasattr(self, 'qt_app') and self.qt_app:
884
+ self.qt_app.quit()