diff --git a/src-tauri/src/achievements.rs b/src-tauri/src/achievements.rs index 7f54cc1..095df88 100644 --- a/src-tauri/src/achievements.rs +++ b/src-tauri/src/achievements.rs @@ -1,6 +1,6 @@ +use chrono::{DateTime, Datelike, Timelike, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use chrono::{DateTime, Utc, Timelike, Datelike}; use tauri_plugin_store::StoreExt; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] @@ -12,9 +12,9 @@ pub enum AchievementId { TokenMaster, // 1,000,000 tokens // Code Generation - HelloWorld, // First code block - CodeWizard, // 100 code blocks - ThousandBlocks, // 1,000 code blocks + HelloWorld, // First code block + CodeWizard, // 100 code blocks + ThousandBlocks, // 1,000 code blocks // File Operations FileManipulator, // 10 files edited @@ -22,23 +22,23 @@ pub enum AchievementId { // Conversation milestones ConversationStarter, // 10 messages - ChattyKathy, // 100 messages - Conversationalist, // 1,000 messages + ChattyKathy, // 100 messages + Conversationalist, // 1,000 messages // Tool usage - Toolsmith, // 5 different tools - ToolMaster, // 10 different tools + Toolsmith, // 5 different tools + ToolMaster, // 10 different tools // Time-based achievements - EarlyBird, // Started session 5-7 AM - NightOwl, // Coding after midnight - AllNighter, // Worked 2-5 AM - WeekendWarrior, // Coding on weekend + EarlyBird, // Started session 5-7 AM + NightOwl, // Coding after midnight + AllNighter, // Worked 2-5 AM + WeekendWarrior, // Coding on weekend DedicatedDeveloper, // 30 days in a row // Search and exploration - Explorer, // 50 searches - MasterSearcher, // 500 searches + Explorer, // 50 searches + MasterSearcher, // 500 searches // Session achievements QuickSession, // Productive session < 5 min @@ -47,36 +47,36 @@ pub enum AchievementId { MarathonSession, // 5+ hour session // Special achievements - FirstMessage, // First message sent - FirstTool, // First tool used - FirstCodeBlock, // First code generated - FirstFileEdit, // First file edit - Polyglot, // 5+ languages in one session - SpeedCoder, // 10 code blocks in 10 minutes + FirstMessage, // First message sent + FirstTool, // First tool used + FirstCodeBlock, // First code generated + FirstFileEdit, // First file edit + Polyglot, // 5+ languages in one session + SpeedCoder, // 10 code blocks in 10 minutes ClaudeConnoisseur, // Used all Claude models - MarathonCoder, // 10k tokens in one session + MarathonCoder, // 10k tokens in one session // Relationship & Greetings - GoodMorning, // Say "good morning" - GoodNight, // Say "good night" or "goodnight" - ThankYou, // Say "thank you" or "thanks" - LoveYou, // Say "love you" or "ily" + GoodMorning, // Say "good morning" + GoodNight, // Say "good night" or "goodnight" + ThankYou, // Say "thank you" or "thanks" + LoveYou, // Say "love you" or "ily" // Personality & Fun - EmojiUser, // Use an emoji in a message - QuestionMaster, // Use "?" in 20 messages - CapsLock, // Send a message in ALL CAPS + EmojiUser, // Use an emoji in a message + QuestionMaster, // Use "?" in 20 messages + CapsLock, // Send a message in ALL CAPS PleaseAndThankYou, // Use "please" in messages // Git & Development - GitGuru, // Use git commands 10 times - TestWriter, // Create test files - Debugger, // Fix bugs (messages with "fix", "bug", "error") + GitGuru, // Use git commands 10 times + TestWriter, // Create test files + Debugger, // Fix bugs (messages with "fix", "bug", "error") // Tool Mastery - BashMaster, // Use Bash tool 50 times - FileExplorer, // Use Read tool 100 times - SearchExpert, // Use Grep tool 50 times + BashMaster, // Use Bash tool 50 times + FileExplorer, // Use Read tool 100 times + SearchExpert, // Use Grep tool 50 times } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -509,15 +509,20 @@ pub fn check_message_achievements( newly_unlocked.push(AchievementId::GoodMorning); } if (message_lower.contains("good night") || message_lower.contains("goodnight")) - && progress.unlock(AchievementId::GoodNight) { + && progress.unlock(AchievementId::GoodNight) + { newly_unlocked.push(AchievementId::GoodNight); } - if (message_lower.contains("thank you") || message_lower.contains("thanks") || message_lower.contains("thx")) - && progress.unlock(AchievementId::ThankYou) { + if (message_lower.contains("thank you") + || message_lower.contains("thanks") + || message_lower.contains("thx")) + && progress.unlock(AchievementId::ThankYou) + { newly_unlocked.push(AchievementId::ThankYou); } if (message_lower.contains("love you") || message_lower.contains("ily")) - && progress.unlock(AchievementId::LoveYou) { + && progress.unlock(AchievementId::LoveYou) + { newly_unlocked.push(AchievementId::LoveYou); } @@ -525,9 +530,11 @@ pub fn check_message_achievements( if message.chars().any(|c| c as u32 >= 0x1F300) && progress.unlock(AchievementId::EmojiUser) { newly_unlocked.push(AchievementId::EmojiUser); } - if message == message.to_uppercase() && message.len() > 5 + if message == message.to_uppercase() + && message.len() > 5 && message.chars().any(|c| c.is_alphabetic()) - && progress.unlock(AchievementId::CapsLock) { + && progress.unlock(AchievementId::CapsLock) + { newly_unlocked.push(AchievementId::CapsLock); } if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) { @@ -535,8 +542,11 @@ pub fn check_message_achievements( } // Git & Development patterns in messages - if (message_lower.contains("fix") || message_lower.contains("bug") || message_lower.contains("error")) - && progress.unlock(AchievementId::Debugger) { + if (message_lower.contains("fix") + || message_lower.contains("bug") + || message_lower.contains("error")) + && progress.unlock(AchievementId::Debugger) + { newly_unlocked.push(AchievementId::Debugger); } @@ -550,10 +560,12 @@ pub fn check_achievements( ) -> Vec { let mut newly_unlocked = Vec::new(); - println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}", + println!( + "Checking achievements with stats: messages={}, tokens={}, code_blocks={}", stats.messages_exchanged, stats.total_input_tokens + stats.total_output_tokens, - stats.code_blocks_generated); + stats.code_blocks_generated + ); println!("Currently unlocked: {:?}", progress.unlocked); // Token milestones @@ -617,7 +629,8 @@ pub fn check_achievements( // Search and exploration let search_tools = ["Glob", "Grep", "search", "Task"]; - let search_count: u64 = search_tools.iter() + let search_count: u64 = search_tools + .iter() .filter_map(|tool| stats.tools_usage.get(*tool)) .sum(); if search_count >= 50 && progress.unlock(AchievementId::Explorer) { @@ -629,7 +642,10 @@ pub fn check_achievements( // Session duration achievements let session_secs = stats.session_duration_seconds; - if session_secs < 300 && stats.session_messages_exchanged >= 5 && progress.unlock(AchievementId::QuickSession) { + if session_secs < 300 + && stats.session_messages_exchanged >= 5 + && progress.unlock(AchievementId::QuickSession) + { newly_unlocked.push(AchievementId::QuickSession); } if session_secs >= 1800 && progress.unlock(AchievementId::FocusedWork) { @@ -716,7 +732,9 @@ pub fn check_achievements( // Weekend warrior use chrono::Weekday; - if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) { + if (weekday == Weekday::Sat || weekday == Weekday::Sun) + && progress.unlock(AchievementId::WeekendWarrior) + { newly_unlocked.push(AchievementId::WeekendWarrior); } } @@ -733,16 +751,21 @@ pub struct AchievementUnlockedEvent { } // Save achievements to persistent store -pub async fn save_achievements(app: &tauri::AppHandle, progress: &AchievementProgress) -> Result<(), String> { - let store = app.store("achievements.json") - .map_err(|e| e.to_string())?; +pub async fn save_achievements( + app: &tauri::AppHandle, + progress: &AchievementProgress, +) -> Result<(), String> { + let store = app.store("achievements.json").map_err(|e| e.to_string())?; // Create a serializable version with just the unlocked achievement IDs let unlocked_list: Vec = progress.unlocked.iter().cloned().collect(); println!("Saving achievements: {:?}", unlocked_list); - store.set("unlocked", serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?); + store.set( + "unlocked", + serde_json::to_value(unlocked_list).map_err(|e| e.to_string())?, + ); store.save().map_err(|e| e.to_string())?; println!("Achievements saved successfully"); @@ -766,7 +789,9 @@ pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress { // Get unlocked achievements if let Some(unlocked_value) = store.get("unlocked") { println!("Found unlocked value in store: {:?}", unlocked_value); - if let Ok(unlocked_list) = serde_json::from_value::>(unlocked_value.clone()) { + if let Ok(unlocked_list) = + serde_json::from_value::>(unlocked_value.clone()) + { println!("Loaded {} achievements", unlocked_list.len()); for achievement_id in unlocked_list { progress.unlocked.insert(achievement_id); @@ -805,4 +830,4 @@ mod tests { let newly = progress.take_newly_unlocked(); assert!(newly.is_empty()); } -} \ No newline at end of file +} diff --git a/src-tauri/src/bridge_manager.rs b/src-tauri/src/bridge_manager.rs index 47638fc..4a2370b 100644 --- a/src-tauri/src/bridge_manager.rs +++ b/src-tauri/src/bridge_manager.rs @@ -30,17 +30,25 @@ impl BridgeManager { options: ClaudeStartOptions, ) -> Result<(), String> { // Check if a bridge already exists and is running for this conversation - if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) { + if self + .bridges + .get(conversation_id) + .map(|b| b.is_running()) + .unwrap_or(false) + { return Err("Claude is already running for this conversation".to_string()); } - let app = self.app_handle.as_ref() + let app = self + .app_handle + .as_ref() .ok_or_else(|| "App handle not set".to_string())? .clone(); // Reuse existing bridge if it exists (preserves stats across reconnects) // Only create a new bridge if one doesn't exist for this conversation - let bridge = self.bridges + let bridge = self + .bridges .entry(conversation_id.to_string()) .or_insert_with(|| WslBridge::new_with_conversation_id(conversation_id.to_string())); @@ -52,7 +60,9 @@ impl BridgeManager { pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> { if let Some(bridge) = self.bridges.get_mut(conversation_id) { - let app = self.app_handle.as_ref() + let app = self + .app_handle + .as_ref() .ok_or_else(|| "App handle not set".to_string())?; bridge.stop(app); Ok(()) @@ -63,7 +73,9 @@ impl BridgeManager { pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> { if let Some(bridge) = self.bridges.get_mut(conversation_id) { - let app = self.app_handle.as_ref() + let app = self + .app_handle + .as_ref() .ok_or_else(|| "App handle not set".to_string())?; bridge.interrupt(app) } else { @@ -79,7 +91,12 @@ impl BridgeManager { } } - pub fn send_tool_result(&mut self, conversation_id: &str, tool_use_id: &str, result: serde_json::Value) -> Result<(), String> { + pub fn send_tool_result( + &mut self, + conversation_id: &str, + tool_use_id: &str, + result: serde_json::Value, + ) -> Result<(), String> { if let Some(bridge) = self.bridges.get_mut(conversation_id) { bridge.send_tool_result(tool_use_id, result) } else { @@ -88,19 +105,22 @@ impl BridgeManager { } pub fn is_claude_running(&self, conversation_id: &str) -> bool { - self.bridges.get(conversation_id) + self.bridges + .get(conversation_id) .map(|b| b.is_running()) .unwrap_or(false) } pub fn get_working_directory(&self, conversation_id: &str) -> Result { - self.bridges.get(conversation_id) + self.bridges + .get(conversation_id) .map(|b| b.get_working_directory().to_string()) .ok_or_else(|| "No Claude instance found for this conversation".to_string()) } pub fn get_usage_stats(&self, conversation_id: &str) -> Result { - self.bridges.get(conversation_id) + self.bridges + .get(conversation_id) .map(|b| b.get_stats()) .ok_or_else(|| "No Claude instance found for this conversation".to_string()) } @@ -123,8 +143,14 @@ impl BridgeManager { #[allow(dead_code)] pub fn get_active_conversations(&self) -> Vec { - self.bridges.keys() - .filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false)) + self.bridges + .keys() + .filter(|id| { + self.bridges + .get(*id) + .map(|b| b.is_running()) + .unwrap_or(false) + }) .cloned() .collect() } @@ -140,4 +166,4 @@ pub type SharedBridgeManager = Arc>; pub fn create_shared_bridge_manager() -> SharedBridgeManager { Arc::new(Mutex::new(BridgeManager::new())) -} \ No newline at end of file +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 36387dc..2b3ce85 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,11 +1,11 @@ use tauri::{AppHandle, State}; -use tauri_plugin_store::StoreExt; use tauri_plugin_http::reqwest; +use tauri_plugin_store::StoreExt; +use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent}; +use crate::bridge_manager::SharedBridgeManager; use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::stats::UsageStats; -use crate::bridge_manager::SharedBridgeManager; -use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent}; const CONFIG_STORE_KEY: &str = "config"; @@ -72,23 +72,17 @@ pub async fn select_wsl_directory() -> Result { #[tauri::command] pub async fn get_config(app: AppHandle) -> Result { - let store = app - .store("hikari-config.json") - .map_err(|e| e.to_string())?; + let store = app.store("hikari-config.json").map_err(|e| e.to_string())?; match store.get(CONFIG_STORE_KEY) { - Some(value) => { - serde_json::from_value(value.clone()).map_err(|e| e.to_string()) - } + Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()), None => Ok(HikariConfig::default()), } } #[tauri::command] pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> { - let store = app - .store("hikari-config.json") - .map_err(|e| e.to_string())?; + let store = app.store("hikari-config.json").map_err(|e| e.to_string())?; let value = serde_json::to_value(&config).map_err(|e| e.to_string())?; store.set(CONFIG_STORE_KEY, value); @@ -107,7 +101,10 @@ pub async fn get_usage_stats( } #[tauri::command] -pub async fn validate_directory(path: String, current_dir: Option) -> Result { +pub async fn validate_directory( + path: String, + current_dir: Option, +) -> Result { use std::path::Path; let path = Path::new(&path); @@ -137,11 +134,17 @@ pub async fn validate_directory(path: String, current_dir: Option) -> Re // Check if the path exists and is a directory if !expanded_path.exists() { - return Err(format!("Directory does not exist: {}", expanded_path.display())); + return Err(format!( + "Directory does not exist: {}", + expanded_path.display() + )); } if !expanded_path.is_dir() { - return Err(format!("Path is not a directory: {}", expanded_path.display())); + return Err(format!( + "Path is not a directory: {}", + expanded_path.display() + )); } // Return the canonicalized (absolute) path @@ -152,7 +155,9 @@ pub async fn validate_directory(path: String, current_dir: Option) -> Re } #[tauri::command] -pub async fn load_saved_achievements(app: AppHandle) -> Result, String> { +pub async fn load_saved_achievements( + app: AppHandle, +) -> Result, String> { use chrono::Utc; // Load achievements from persistent store @@ -163,9 +168,7 @@ pub async fn load_saved_achievements(app: AppHandle) -> Result Result, String> { - use std::path::Path; use std::fs; + use std::path::Path; // Get the home directory - let home = std::env::var_os("HOME") - .ok_or_else(|| "Could not determine home directory".to_string())?; + let home = + std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?; let skills_dir = Path::new(&home).join(".claude").join("skills"); @@ -200,8 +203,8 @@ pub async fn list_skills() -> Result, String> { // Read the directory and collect skill names let mut skills = Vec::new(); - let entries = fs::read_dir(&skills_dir) - .map_err(|e| format!("Failed to read skills directory: {}", e))?; + let entries = + fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?; for entry in entries { let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; @@ -244,7 +247,8 @@ struct GiteaRelease { #[tauri::command] pub async fn check_for_updates() -> Result { const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); - const RELEASES_API: &str = "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases"; + const RELEASES_API: &str = + "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases"; // Fetch releases from Gitea API let client = reqwest::Client::new(); @@ -264,8 +268,8 @@ pub async fn check_for_updates() -> Result { .await .map_err(|e| format!("Failed to read response: {}", e))?; - let releases: Vec = serde_json::from_str(&text) - .map_err(|e| format!("Failed to parse releases: {}", e))?; + let releases: Vec = + serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?; // Find the latest non-prerelease, or fall back to latest prerelease let latest = releases diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index dd2714c..d6f6fae 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -67,6 +67,9 @@ pub struct HikariConfig { #[serde(default)] pub character_panel_width: Option, + + #[serde(default = "default_font_size")] + pub font_size: u32, } impl Default for HikariConfig { @@ -85,6 +88,7 @@ impl Default for HikariConfig { always_on_top: false, update_checks_enabled: true, character_panel_width: None, + font_size: 14, } } } @@ -105,6 +109,10 @@ fn default_notification_volume() -> f32 { 0.7 } +fn default_font_size() -> u32 { + 14 +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] #[serde(rename_all = "lowercase")] pub enum Theme { @@ -131,6 +139,7 @@ mod tests { assert!(!config.always_on_top); assert!(config.update_checks_enabled); assert!(config.character_panel_width.is_none()); + assert_eq!(config.font_size, 14); } #[test] @@ -149,6 +158,7 @@ mod tests { always_on_top: true, update_checks_enabled: true, character_panel_width: Some(400), + font_size: 16, }; let json = serde_json::to_string(&config).unwrap(); @@ -159,7 +169,10 @@ mod tests { assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools); assert_eq!(deserialized.theme, Theme::Light); assert!(deserialized.greeting_enabled); - assert_eq!(deserialized.greeting_custom_prompt, Some("Hello!".to_string())); + assert_eq!( + deserialized.greeting_custom_prompt, + Some("Hello!".to_string()) + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a369ec..685d3da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,18 +5,18 @@ mod config; mod notifications; mod stats; mod types; -mod wsl_bridge; -mod wsl_notifications; mod vbs_notification; mod windows_toast; +mod wsl_bridge; +mod wsl_notifications; -use commands::*; -use notifications::*; use bridge_manager::create_shared_bridge_manager; use commands::load_saved_achievements; -use wsl_notifications::*; +use commands::*; +use notifications::*; use vbs_notification::*; use windows_toast::*; +use wsl_notifications::*; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index d0f421c..6d0ed2e 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -1,5 +1,5 @@ -use tauri::command; use std::process::Command; +use tauri::command; #[command] pub async fn send_notify_send(title: String, body: String) -> Result<(), String> { @@ -10,7 +10,12 @@ pub async fn send_notify_send(title: String, body: String) -> Result<(), String> .arg("--urgency=normal") .arg("--app-name=Hikari Desktop") .output() - .map_err(|e| format!("Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", e))?; + .map_err(|e| { + format!( + "Failed to execute notify-send: {}. Make sure libnotify-bin is installed.", + e + ) + })?; if !output.status.success() { let error = String::from_utf8_lossy(&output.stderr); @@ -93,4 +98,4 @@ pub async fn send_simple_notification(title: String, body: String) -> Result<(), .map_err(|e| format!("Failed to send message: {}", e))?; Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs index d174bd0..d04d7a4 100644 --- a/src-tauri/src/stats.rs +++ b/src-tauri/src/stats.rs @@ -1,7 +1,7 @@ +use crate::achievements::{check_achievements, AchievementProgress}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Instant; -use crate::achievements::{AchievementProgress, check_achievements}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct UsageStats { @@ -89,7 +89,10 @@ impl UsageStats { pub fn increment_tool_usage(&mut self, tool_name: &str) { *self.tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; - *self.session_tools_usage.entry(tool_name.to_string()).or_insert(0) += 1; + *self + .session_tools_usage + .entry(tool_name.to_string()) + .or_insert(0) += 1; } pub fn get_session_duration(&mut self) -> u64 { @@ -213,4 +216,4 @@ mod tests { assert_eq!(stats.session_cost_usd, 0.0); assert!(stats.total_cost_usd > 0.0); } -} \ No newline at end of file +} diff --git a/src-tauri/src/vbs_notification.rs b/src-tauri/src/vbs_notification.rs index 3667a6e..f100d91 100644 --- a/src-tauri/src/vbs_notification.rs +++ b/src-tauri/src/vbs_notification.rs @@ -1,7 +1,7 @@ -use std::process::Command; use std::io::Write; -use tempfile::NamedTempFile; +use std::process::Command; use tauri::command; +use tempfile::NamedTempFile; #[command] pub async fn send_vbs_notification(title: String, body: String) -> Result<(), String> { @@ -17,8 +17,8 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 ); // Create a temporary VBS file - let mut temp_file = NamedTempFile::new() - .map_err(|e| format!("Failed to create temp file: {}", e))?; + let mut temp_file = + NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {}", e))?; temp_file .write_all(vbs_content.as_bytes()) @@ -40,10 +40,7 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 } else if temp_path.starts_with("/tmp/") { // WSL temp files might be in a different location // Try to use wslpath to convert - let output = Command::new("wslpath") - .arg("-w") - .arg(&temp_path) - .output(); + let output = Command::new("wslpath").arg("-w").arg(&temp_path).output(); if let Ok(result) = output { if result.status.success() { @@ -71,4 +68,4 @@ objShell.Popup "{}" & vbCrLf & vbCrLf & "{}", 5, "{}", 64 } Ok(()) -} \ No newline at end of file +} diff --git a/src-tauri/src/windows_toast.rs b/src-tauri/src/windows_toast.rs index 8bf3e48..fd75676 100644 --- a/src-tauri/src/windows_toast.rs +++ b/src-tauri/src/windows_toast.rs @@ -2,7 +2,7 @@ use tauri::command; #[cfg(target_os = "windows")] use windows::{ - core::{HSTRING, Result as WindowsResult}, + core::{Result as WindowsResult, HSTRING}, Data::Xml::Dom::*, UI::Notifications::*, }; @@ -38,7 +38,8 @@ fn show_toast_notification(title: &str, body: &str) -> WindowsResult<()> { let toast = ToastNotification::CreateToastNotification(&xml_doc)?; // Create a toast notifier with an application ID - let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?; + let notifier = + ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from("Hikari Desktop"))?; // Show the notification notifier.Show(&toast)?; @@ -60,4 +61,4 @@ fn escape_xml(text: &str) -> String { #[command] pub async fn send_windows_toast(_title: String, _body: String) -> Result<(), String> { Err("Windows toast notifications are only available on Windows".to_string()) -} \ No newline at end of file +} diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 13e761d..e0a6fcd 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -8,11 +8,15 @@ use tempfile::NamedTempFile; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; -use crate::config::ClaudeStartOptions; -use crate::stats::{UsageStats, StatsUpdateEvent}; -use parking_lot::RwLock; -use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent, UserQuestionEvent, QuestionOption}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; +use crate::config::ClaudeStartOptions; +use crate::stats::{StatsUpdateEvent, UsageStats}; +use crate::types::{ + CharacterState, ClaudeMessage, ConnectionEvent, ConnectionStatus, ContentBlock, OutputEvent, + PermissionPromptEvent, QuestionOption, SessionEvent, StateChangeEvent, UserQuestionEvent, + WorkingDirectoryEvent, +}; +use parking_lot::RwLock; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; @@ -103,7 +107,6 @@ impl WslBridge { } } - pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { if self.process.is_some() { return Err("Process already running".to_string()); @@ -115,14 +118,21 @@ impl WslBridge { tauri::async_runtime::spawn(async move { println!("Loading saved achievements..."); let achievements = crate::achievements::load_achievements(&app_clone).await; - println!("Loaded {} unlocked achievements", achievements.unlocked.len()); + println!( + "Loaded {} unlocked achievements", + achievements.unlocked.len() + ); stats.write().achievements = achievements; }); let working_dir = &options.working_dir; self.working_directory = working_dir.clone(); - emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone()); + emit_connection_status( + &app, + ConnectionStatus::Connecting, + self.conversation_id.clone(), + ); // Create temp file for MCP config if provided let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { @@ -158,16 +168,19 @@ impl WslBridge { let mut command = if is_wsl { // Running inside WSL - call claude directly // Try to find claude in common locations since GUI apps may not inherit shell PATH - let claude_path = find_claude_binary() - .ok_or_else(|| "Could not find claude binary. Is Claude Code installed?".to_string())?; + let claude_path = find_claude_binary().ok_or_else(|| { + "Could not find claude binary. Is Claude Code installed?".to_string() + })?; eprintln!("[DEBUG] Found claude at: {}", claude_path); eprintln!("[DEBUG] Working dir: {}", working_dir); let mut cmd = Command::new(&claude_path); cmd.args([ - "--output-format", "stream-json", - "--input-format", "stream-json", + "--output-format", + "stream-json", + "--input-format", + "stream-json", "--verbose", ]); @@ -218,10 +231,7 @@ impl WslBridge { let mut cmd = Command::new("wsl"); // Build the claude command with all arguments - let mut claude_cmd = format!( - "cd '{}' && ", - working_dir - ); + let mut claude_cmd = format!("cd '{}' && ", working_dir); // Set API key as environment variable if specified if let Some(ref api_key) = options.api_key { @@ -230,7 +240,9 @@ impl WslBridge { } } - claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose"); + claude_cmd.push_str( + "claude --output-format stream-json --input-format stream-json --verbose", + ); // Add model if specified if let Some(ref model) = options.model { @@ -320,7 +332,11 @@ impl WslBridge { }); } - emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone()); + emit_connection_status( + &app, + ConnectionStatus::Connected, + self.conversation_id.clone(), + ); Ok(()) } @@ -345,12 +361,18 @@ impl WslBridge { .write_all(format!("{}\n", json_line).as_bytes()) .map_err(|e| format!("Failed to write to stdin: {}", e))?; - stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; + stdin + .flush() + .map_err(|e| format!("Failed to flush stdin: {}", e))?; Ok(()) } - pub fn send_tool_result(&mut self, tool_use_id: &str, result: serde_json::Value) -> Result<(), String> { + pub fn send_tool_result( + &mut self, + tool_use_id: &str, + result: serde_json::Value, + ) -> Result<(), String> { let stdin = self.stdin.as_mut().ok_or("Process not running")?; // The content should be a JSON string representation of the result @@ -374,7 +396,9 @@ impl WslBridge { .write_all(format!("{}\n", json_line).as_bytes()) .map_err(|e| format!("Failed to write to stdin: {}", e))?; - stdin.flush().map_err(|e| format!("Failed to flush stdin: {}", e))?; + stdin + .flush() + .map_err(|e| format!("Failed to flush stdin: {}", e))?; Ok(()) } @@ -395,7 +419,11 @@ impl WslBridge { // The user will see what session was interrupted // Emit disconnected status - emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); + emit_connection_status( + app, + ConnectionStatus::Disconnected, + self.conversation_id.clone(), + ); Ok(()) } else { @@ -415,7 +443,11 @@ impl WslBridge { // Reset session stats on explicit disconnect self.stats.write().reset_session(); - emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); + emit_connection_status( + app, + ConnectionStatus::Disconnected, + self.conversation_id.clone(), + ); } pub fn is_running(&self) -> bool { @@ -429,7 +461,6 @@ impl WslBridge { pub fn get_stats(&self) -> UsageStats { self.stats.read().clone() } - } impl Default for WslBridge { @@ -438,7 +469,12 @@ impl Default for WslBridge { } } -fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>, conversation_id: Option) { +fn handle_stdout( + stdout: std::process::ChildStdout, + app: AppHandle, + stats: Arc>, + conversation_id: Option, +) { let reader = BufReader::new(stdout); for line in reader.lines() { @@ -459,18 +495,25 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc) { +fn handle_stderr( + stderr: std::process::ChildStderr, + app: AppHandle, + conversation_id: Option, +) { let reader = BufReader::new(stderr); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { - let _ = app.emit("claude:output", OutputEvent { - line_type: "error".to_string(), - content: line, - tool_name: None, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: line, + tool_name: None, + conversation_id: conversation_id.clone(), + }, + ); } Err(_) => break, _ => {} @@ -478,24 +521,40 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation } } -fn process_json_line(line: &str, app: &AppHandle, stats: &Arc>, conversation_id: &Option) -> Result<(), String> { +fn process_json_line( + line: &str, + app: &AppHandle, + stats: &Arc>, + conversation_id: &Option, +) -> Result<(), String> { let message: ClaudeMessage = serde_json::from_str(line) .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; match &message { - ClaudeMessage::System { subtype, session_id, cwd, .. } => { + ClaudeMessage::System { + subtype, + session_id, + cwd, + .. + } => { if subtype == "init" { if let Some(id) = session_id { - let _ = app.emit("claude:session", SessionEvent { - session_id: id.clone(), - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:session", + SessionEvent { + session_id: id.clone(), + conversation_id: conversation_id.clone(), + }, + ); } if let Some(dir) = cwd { - let _ = app.emit("claude:cwd", WorkingDirectoryEvent { - directory: dir.clone(), - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:cwd", + WorkingDirectoryEvent { + directory: dir.clone(), + conversation_id: conversation_id.clone(), + }, + ); } emit_state_change(app, CharacterState::Idle, None, conversation_id.clone()); } @@ -547,12 +606,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc } let desc = format_tool_description(name, input); - let _ = app.emit("claude:output", OutputEvent { - line_type: "tool".to_string(), - content: desc, - tool_name: Some(name.clone()), - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "tool".to_string(), + content: desc, + tool_name: Some(name.clone()), + conversation_id: conversation_id.clone(), + }, + ); } ContentBlock::Text { text } => { // Count code blocks in the text @@ -561,21 +623,27 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc stats.write().increment_code_blocks(); } - let _ = app.emit("claude:output", OutputEvent { - line_type: "assistant".to_string(), - content: text.clone(), - tool_name: None, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "assistant".to_string(), + content: text.clone(), + tool_name: None, + conversation_id: conversation_id.clone(), + }, + ); } ContentBlock::Thinking { thinking } => { state = CharacterState::Thinking; - let _ = app.emit("claude:output", OutputEvent { - line_type: "system".to_string(), - content: format!("[Thinking] {}", thinking), - tool_name: None, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "system".to_string(), + content: format!("[Thinking] {}", thinking), + tool_name: None, + conversation_id: conversation_id.clone(), + }, + ); } _ => {} } @@ -610,7 +678,13 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc } } - ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => { + ClaudeMessage::Result { + subtype, + result, + permission_denials, + usage: _, + .. + } => { let state = if subtype == "success" { CharacterState::Success } else { @@ -631,9 +705,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc // Emit achievement events for any newly unlocked achievements for achievement_id in &newly_unlocked { let info = get_achievement_info(achievement_id); - let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { - achievement: info, - }); + let _ = app.emit( + "achievement:unlocked", + AchievementUnlockedEvent { achievement: info }, + ); } // Save achievements after unlocking new ones @@ -645,7 +720,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc // Use Tauri's async runtime instead of tokio::spawn tauri::async_runtime::spawn(async move { println!("Spawned save task for achievements"); - if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { + if let Err(e) = + crate::achievements::save_achievements(&app_handle, &achievements_progress) + .await + { eprintln!("Failed to save achievements: {}", e); } else { println!("Achievement save task completed successfully"); @@ -662,12 +740,15 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc // Only emit error results - success content is already sent via Assistant message if subtype != "success" { if let Some(text) = result { - let _ = app.emit("claude:output", OutputEvent { - line_type: "error".to_string(), - content: text.clone(), - tool_name: None, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:output", + OutputEvent { + line_type: "error".to_string(), + content: text.clone(), + tool_name: None, + conversation_id: conversation_id.clone(), + }, + ); } } @@ -678,64 +759,88 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc for denial in denials { // Special handling for AskUserQuestion tool if denial.tool_name == "AskUserQuestion" { - if let Some(questions) = denial.tool_input.get("questions").and_then(|q| q.as_array()) { + if let Some(questions) = denial + .tool_input + .get("questions") + .and_then(|q| q.as_array()) + { // For now, handle the first question (most common case) if let Some(first_question) = questions.first() { - let question_text = first_question.get("question") + let question_text = first_question + .get("question") .and_then(|q| q.as_str()) .unwrap_or("Claude has a question for you") .to_string(); - let header = first_question.get("header") + let header = first_question + .get("header") .and_then(|h| h.as_str()) .map(|s| s.to_string()); - let multi_select = first_question.get("multiSelect") + let multi_select = first_question + .get("multiSelect") .and_then(|m| m.as_bool()) .unwrap_or(false); - let options: Vec = first_question.get("options") + let options: Vec = first_question + .get("options") .and_then(|opts| opts.as_array()) .map(|opts| { - opts.iter().filter_map(|opt| { - let label = opt.get("label").and_then(|l| l.as_str())?; - let description = opt.get("description") - .and_then(|d| d.as_str()) - .map(|s| s.to_string()); - Some(QuestionOption { - label: label.to_string(), - description, + opts.iter() + .filter_map(|opt| { + let label = + opt.get("label").and_then(|l| l.as_str())?; + let description = opt + .get("description") + .and_then(|d| d.as_str()) + .map(|s| s.to_string()); + Some(QuestionOption { + label: label.to_string(), + description, + }) }) - }).collect() + .collect() }) .unwrap_or_default(); - let _ = app.emit("claude:question", UserQuestionEvent { - id: denial.tool_use_id.clone(), - question: question_text, - header, - options, - multi_select, - conversation_id: conversation_id.clone(), - }); + let _ = app.emit( + "claude:question", + UserQuestionEvent { + id: denial.tool_use_id.clone(), + question: question_text, + header, + options, + multi_select, + conversation_id: conversation_id.clone(), + }, + ); } } } else { has_regular_denials = true; - let description = format_tool_description(&denial.tool_name, &denial.tool_input); - let _ = app.emit("claude:permission", PermissionPromptEvent { - id: denial.tool_use_id.clone(), - tool_name: denial.tool_name.clone(), - tool_input: denial.tool_input.clone(), - description, - conversation_id: conversation_id.clone(), - }); + let description = + format_tool_description(&denial.tool_name, &denial.tool_input); + let _ = app.emit( + "claude:permission", + PermissionPromptEvent { + id: denial.tool_use_id.clone(), + tool_name: denial.tool_name.clone(), + tool_input: denial.tool_input.clone(), + description, + conversation_id: conversation_id.clone(), + }, + ); } } // Show permission state if there were any denials (questions or regular) if has_regular_denials || !denials.is_empty() { - emit_state_change(app, CharacterState::Permission, None, conversation_id.clone()); + emit_state_change( + app, + CharacterState::Permission, + None, + conversation_id.clone(), + ); return Ok(()); } } @@ -748,7 +853,9 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc stats.write().increment_messages(); // Extract text content from the message - let message_text = message.content.iter() + let message_text = message + .content + .iter() .filter_map(|block| match block { crate::types::ContentBlock::Text { text } => Some(text.clone()), _ => None, @@ -778,9 +885,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc for achievement_id in &newly_unlocked { println!("User message unlocked achievement: {:?}", achievement_id); let info = get_achievement_info(achievement_id); - let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent { - achievement: info, - }); + let _ = app.emit( + "achievement:unlocked", + AchievementUnlockedEvent { achievement: info }, + ); } // Save achievements after unlocking new ones @@ -789,7 +897,10 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc let app_handle = app.clone(); let achievements_progress = stats.read().achievements.clone(); tauri::async_runtime::spawn(async move { - if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await { + if let Err(e) = + crate::achievements::save_achievements(&app_handle, &achievements_progress) + .await + { eprintln!("Failed to save achievements: {}", e); } else { println!("Achievements saved after user message"); @@ -864,15 +975,36 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String { } } -fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option, conversation_id: Option) { - let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id }); +fn emit_state_change( + app: &AppHandle, + state: CharacterState, + tool_name: Option, + conversation_id: Option, +) { + let _ = app.emit( + "claude:state", + StateChangeEvent { + state, + tool_name, + conversation_id, + }, + ); } -fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option) { - let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id }); +fn emit_connection_status( + app: &AppHandle, + status: ConnectionStatus, + conversation_id: Option, +) { + let _ = app.emit( + "claude:connection", + ConnectionEvent { + status, + conversation_id, + }, + ); } - #[cfg(test)] mod tests { use super::*; @@ -882,21 +1014,36 @@ mod tests { assert!(matches!(get_tool_state("Read"), CharacterState::Searching)); assert!(matches!(get_tool_state("Glob"), CharacterState::Searching)); assert!(matches!(get_tool_state("Grep"), CharacterState::Searching)); - assert!(matches!(get_tool_state("WebSearch"), CharacterState::Searching)); - assert!(matches!(get_tool_state("WebFetch"), CharacterState::Searching)); + assert!(matches!( + get_tool_state("WebSearch"), + CharacterState::Searching + )); + assert!(matches!( + get_tool_state("WebFetch"), + CharacterState::Searching + )); } #[test] fn test_get_tool_state_coding_tools() { assert!(matches!(get_tool_state("Edit"), CharacterState::Coding)); assert!(matches!(get_tool_state("Write"), CharacterState::Coding)); - assert!(matches!(get_tool_state("NotebookEdit"), CharacterState::Coding)); + assert!(matches!( + get_tool_state("NotebookEdit"), + CharacterState::Coding + )); } #[test] fn test_get_tool_state_mcp_tools() { - assert!(matches!(get_tool_state("mcp__github__create_issue"), CharacterState::Mcp)); - assert!(matches!(get_tool_state("mcp__notion__search"), CharacterState::Mcp)); + assert!(matches!( + get_tool_state("mcp__github__create_issue"), + CharacterState::Mcp + )); + assert!(matches!( + get_tool_state("mcp__notion__search"), + CharacterState::Mcp + )); } #[test] @@ -906,7 +1053,10 @@ mod tests { #[test] fn test_get_tool_state_unknown() { - assert!(matches!(get_tool_state("SomeUnknownTool"), CharacterState::Typing)); + assert!(matches!( + get_tool_state("SomeUnknownTool"), + CharacterState::Typing + )); assert!(matches!(get_tool_state("Bash"), CharacterState::Typing)); } diff --git a/src-tauri/src/wsl_notifications.rs b/src-tauri/src/wsl_notifications.rs index ea61d5c..2d9752c 100644 --- a/src-tauri/src/wsl_notifications.rs +++ b/src-tauri/src/wsl_notifications.rs @@ -81,4 +81,4 @@ $notifier.Show($toast) // If all methods fail, return an error Err("All WSL notification methods failed".to_string()) -} \ No newline at end of file +} diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte index 7c242b9..8981690 100644 --- a/src/lib/components/ConfigSidebar.svelte +++ b/src/lib/components/ConfigSidebar.svelte @@ -1,5 +1,13 @@