Add system tray icon with menu for agent

- Added tray-icon and muda crates for tray functionality
- Tray icon shows green circle when connected
- Menu displays: session code, machine name, End Session option
- End Session menu item cleanly terminates the agent
- Tray events processed in session main loop

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 16:06:16 -07:00
parent 8246d135f9
commit dea96bd300
5 changed files with 1256 additions and 56 deletions

157
agent/src/tray/mod.rs Normal file
View File

@@ -0,0 +1,157 @@
//! System tray icon and menu for the agent
//!
//! Provides a tray icon with menu options:
//! - Connection status
//! - Machine name
//! - End session
use anyhow::Result;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tray_icon::{Icon, TrayIcon, TrayIconBuilder, TrayIconEvent};
use tracing::{info, warn};
/// Events that can be triggered from the tray menu
#[derive(Debug, Clone)]
pub enum TrayAction {
EndSession,
ShowDetails,
// Future: OpenChat,
}
/// Tray icon controller
pub struct TrayController {
_tray_icon: TrayIcon,
menu: Menu,
end_session_item: MenuItem,
status_item: MenuItem,
exit_requested: Arc<AtomicBool>,
}
impl TrayController {
/// Create a new tray controller
pub fn new(machine_name: &str, support_code: Option<&str>) -> Result<Self> {
// Create menu items
let status_text = if let Some(code) = support_code {
format!("Support Session: {}", code)
} else {
"Connected".to_string()
};
let status_item = MenuItem::new(&status_text, false, None);
let machine_item = MenuItem::new(format!("Machine: {}", machine_name), false, None);
let separator = PredefinedMenuItem::separator();
let end_session_item = MenuItem::new("End Session", true, None);
// Build menu
let menu = Menu::new();
menu.append(&status_item)?;
menu.append(&machine_item)?;
menu.append(&separator)?;
menu.append(&end_session_item)?;
// Create tray icon
let icon = create_default_icon()?;
let tray_icon = TrayIconBuilder::new()
.with_menu(Box::new(menu.clone()))
.with_tooltip(format!("GuruConnect - {}", machine_name))
.with_icon(icon)
.build()?;
let exit_requested = Arc::new(AtomicBool::new(false));
Ok(Self {
_tray_icon: tray_icon,
menu,
end_session_item,
status_item,
exit_requested,
})
}
/// Check if exit has been requested
pub fn exit_requested(&self) -> bool {
self.exit_requested.load(Ordering::SeqCst)
}
/// Update the connection status display
pub fn update_status(&self, status: &str) {
self.status_item.set_text(status);
}
/// Process pending menu events (call this from the main loop)
pub fn process_events(&self) -> Option<TrayAction> {
// Check for menu events
if let Ok(event) = MenuEvent::receiver().try_recv() {
if event.id == self.end_session_item.id() {
info!("End session requested from tray menu");
self.exit_requested.store(true, Ordering::SeqCst);
return Some(TrayAction::EndSession);
}
}
// Check for tray icon events (like double-click)
if let Ok(event) = TrayIconEvent::receiver().try_recv() {
match event {
TrayIconEvent::DoubleClick { .. } => {
info!("Tray icon double-clicked");
return Some(TrayAction::ShowDetails);
}
_ => {}
}
}
None
}
}
/// Create a simple default icon (green circle for connected)
fn create_default_icon() -> Result<Icon> {
// Create a simple 32x32 green icon
let size = 32u32;
let mut rgba = vec![0u8; (size * size * 4) as usize];
let center = size as f32 / 2.0;
let radius = size as f32 / 2.0 - 2.0;
for y in 0..size {
for x in 0..size {
let dx = x as f32 - center;
let dy = y as f32 - center;
let dist = (dx * dx + dy * dy).sqrt();
let idx = ((y * size + x) * 4) as usize;
if dist <= radius {
// Green circle
rgba[idx] = 76; // R
rgba[idx + 1] = 175; // G
rgba[idx + 2] = 80; // B
rgba[idx + 3] = 255; // A
} else if dist <= radius + 1.0 {
// Anti-aliased edge
let alpha = ((radius + 1.0 - dist) * 255.0) as u8;
rgba[idx] = 76;
rgba[idx + 1] = 175;
rgba[idx + 2] = 80;
rgba[idx + 3] = alpha;
}
}
}
let icon = Icon::from_rgba(rgba, size, size)?;
Ok(icon)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_icon() {
let icon = create_default_icon();
assert!(icon.is_ok());
}
}