From 70fcaa865002d2f1fbb660109d6853aae2f3ab74 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 19 Jan 2026 20:51:53 -0800 Subject: [PATCH] feat: stats and achievements (#45) ### Explanation _No response_ ### Issue Closes #39 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/45 Co-authored-by: Naomi Carrigan Co-committed-by: Naomi Carrigan --- src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 1 + src-tauri/src/achievements.rs | 808 ++++++++++++++++++ src-tauri/src/commands.rs | 28 + src-tauri/src/lib.rs | 5 + src-tauri/src/stats.rs | 216 +++++ src-tauri/src/types.rs | 10 + src-tauri/src/wsl_bridge.rs | 201 ++++- src/app.css | 14 + .../components/AchievementNotification.svelte | 202 +++++ src/lib/components/AchievementsPanel.svelte | 266 ++++++ src/lib/components/InputBar.svelte | 2 +- src/lib/components/StatsDisplay.svelte | 168 ++++ src/lib/components/StatusBar.svelte | 59 ++ src/lib/components/Terminal.svelte | 69 +- src/lib/notifications/types.ts | 7 + src/lib/sounds/achievement.ts | 19 + src/lib/stores/achievements.ts | 649 ++++++++++++++ src/lib/stores/stats.ts | 130 +++ src/lib/tauri.ts | 9 + src/lib/types/achievements.ts | 93 ++ src/routes/+page.svelte | 10 +- src/routes/test-achievement/+page.svelte | 45 + static/sounds/achievement.mp3 | Bin 0 -> 14829 bytes 24 files changed, 2995 insertions(+), 19 deletions(-) create mode 100644 src-tauri/src/achievements.rs create mode 100644 src-tauri/src/stats.rs create mode 100644 src/lib/components/AchievementNotification.svelte create mode 100644 src/lib/components/AchievementsPanel.svelte create mode 100644 src/lib/components/StatsDisplay.svelte create mode 100644 src/lib/sounds/achievement.ts create mode 100644 src/lib/stores/achievements.ts create mode 100644 src/lib/stores/stats.ts create mode 100644 src/lib/types/achievements.ts create mode 100644 src/routes/test-achievement/+page.svelte create mode 100644 static/sounds/achievement.mp3 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a95f0e3..8632aab 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -442,8 +442,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -1411,6 +1413,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" name = "hikari-desktop" version = "0.1.0" dependencies = [ + "chrono", "parking_lot", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cb622d0..e377513 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ tauri-plugin-store = "2.4.2" tauri-plugin-notification = "2" tauri-plugin-os = "2" tempfile = "3" +chrono = { version = "0.4.43", features = ["serde"] } [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/src-tauri/src/achievements.rs b/src-tauri/src/achievements.rs new file mode 100644 index 0000000..7f54cc1 --- /dev/null +++ b/src-tauri/src/achievements.rs @@ -0,0 +1,808 @@ +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)] +pub enum AchievementId { + // Token Milestones + FirstSteps, // 1,000 tokens + GrowingStrong, // 10,000 tokens + BlossomingCoder, // 100,000 tokens + TokenMaster, // 1,000,000 tokens + + // Code Generation + HelloWorld, // First code block + CodeWizard, // 100 code blocks + ThousandBlocks, // 1,000 code blocks + + // File Operations + FileManipulator, // 10 files edited + FileArchitect, // 100 files edited + + // Conversation milestones + ConversationStarter, // 10 messages + ChattyKathy, // 100 messages + Conversationalist, // 1,000 messages + + // Tool usage + 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 + DedicatedDeveloper, // 30 days in a row + + // Search and exploration + Explorer, // 50 searches + MasterSearcher, // 500 searches + + // Session achievements + QuickSession, // Productive session < 5 min + FocusedWork, // 30 min session + DeepDive, // 2 hour session + 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 + ClaudeConnoisseur, // Used all Claude models + 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" + + // Personality & Fun + 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") + + // Tool Mastery + BashMaster, // Use Bash tool 50 times + FileExplorer, // Use Read tool 100 times + SearchExpert, // Use Grep tool 50 times +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Achievement { + pub id: AchievementId, + pub name: String, + pub description: String, + pub icon: String, + pub unlocked_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AchievementProgress { + pub unlocked: HashSet, + pub newly_unlocked: Vec, // Achievements unlocked but not yet notified + #[serde(skip)] + pub session_start: Option>, +} + +impl AchievementProgress { + pub fn new() -> Self { + Self { + unlocked: HashSet::new(), + newly_unlocked: Vec::new(), + session_start: None, + } + } + + pub fn unlock(&mut self, achievement: AchievementId) -> bool { + if self.unlocked.insert(achievement.clone()) { + self.newly_unlocked.push(achievement); + true + } else { + false + } + } + + #[cfg(test)] + pub fn take_newly_unlocked(&mut self) -> Vec { + std::mem::take(&mut self.newly_unlocked) + } + + #[cfg(test)] + pub fn is_unlocked(&self, achievement: &AchievementId) -> bool { + self.unlocked.contains(achievement) + } + + pub fn start_session(&mut self) { + self.session_start = Some(Utc::now()); + } +} + +impl Default for AchievementProgress { + fn default() -> Self { + Self::new() + } +} + +pub fn get_achievement_info(id: &AchievementId) -> Achievement { + match id { + // Token Milestones + AchievementId::FirstSteps => Achievement { + id: id.clone(), + name: "First Steps!".to_string(), + description: "Used 1,000 tokens".to_string(), + icon: "๐ŸŒฑ".to_string(), + unlocked_at: None, + }, + AchievementId::GrowingStrong => Achievement { + id: id.clone(), + name: "Growing Strong!".to_string(), + description: "Used 10,000 tokens".to_string(), + icon: "๐ŸŒธ".to_string(), + unlocked_at: None, + }, + AchievementId::BlossomingCoder => Achievement { + id: id.clone(), + name: "Blossoming Coder!".to_string(), + description: "Used 100,000 tokens".to_string(), + icon: "๐ŸŒบ".to_string(), + unlocked_at: None, + }, + AchievementId::TokenMaster => Achievement { + id: id.clone(), + name: "Token Master!".to_string(), + description: "Used 1,000,000 tokens".to_string(), + icon: "๐ŸŒŸ".to_string(), + unlocked_at: None, + }, + + // Code Generation + AchievementId::HelloWorld => Achievement { + id: id.clone(), + name: "Hello World!".to_string(), + description: "Generated your first code block".to_string(), + icon: "๐Ÿ“".to_string(), + unlocked_at: None, + }, + AchievementId::CodeWizard => Achievement { + id: id.clone(), + name: "Code Wizard!".to_string(), + description: "Generated 100 code blocks".to_string(), + icon: "๐ŸŽฏ".to_string(), + unlocked_at: None, + }, + AchievementId::ThousandBlocks => Achievement { + id: id.clone(), + name: "Thousand Blocks".to_string(), + description: "1,000 code blocks! You're a code machine!".to_string(), + icon: "๐Ÿ—๏ธ".to_string(), + unlocked_at: None, + }, + + // File Operations + AchievementId::FileManipulator => Achievement { + id: id.clone(), + name: "File Manipulator".to_string(), + description: "Edited 10 files".to_string(), + icon: "๐Ÿ“".to_string(), + unlocked_at: None, + }, + AchievementId::FileArchitect => Achievement { + id: id.clone(), + name: "File Architect".to_string(), + description: "Created or edited 100 files".to_string(), + icon: "๐Ÿ›๏ธ".to_string(), + unlocked_at: None, + }, + + // Conversation milestones + AchievementId::ConversationStarter => Achievement { + id: id.clone(), + name: "Conversation Starter".to_string(), + description: "Exchanged 10 messages".to_string(), + icon: "๐Ÿ’ฌ".to_string(), + unlocked_at: None, + }, + AchievementId::ChattyKathy => Achievement { + id: id.clone(), + name: "Chatty Kathy".to_string(), + description: "100 messages exchanged".to_string(), + icon: "๐Ÿ—ฃ๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::Conversationalist => Achievement { + id: id.clone(), + name: "Master Conversationalist".to_string(), + description: "1,000 messages! We're really connecting!".to_string(), + icon: "๐Ÿ’–".to_string(), + unlocked_at: None, + }, + + // Tool usage + AchievementId::Toolsmith => Achievement { + id: id.clone(), + name: "Toolsmith".to_string(), + description: "Used 5 different tools".to_string(), + icon: "๐Ÿ”จ".to_string(), + unlocked_at: None, + }, + AchievementId::ToolMaster => Achievement { + id: id.clone(), + name: "Tool Master".to_string(), + description: "Used 10 different tools efficiently".to_string(), + icon: "๐Ÿ› ๏ธ".to_string(), + unlocked_at: None, + }, + + // Time-based achievements + AchievementId::EarlyBird => Achievement { + id: id.clone(), + name: "Early Bird".to_string(), + description: "Started a session between 5 AM and 7 AM".to_string(), + icon: "๐ŸŒ…".to_string(), + unlocked_at: None, + }, + AchievementId::NightOwl => Achievement { + id: id.clone(), + name: "Night Owl".to_string(), + description: "Coding after midnight".to_string(), + icon: "๐Ÿฆ‰".to_string(), + unlocked_at: None, + }, + AchievementId::AllNighter => Achievement { + id: id.clone(), + name: "All Nighter".to_string(), + description: "Worked through the night (2 AM - 5 AM)".to_string(), + icon: "๐ŸŒ™".to_string(), + unlocked_at: None, + }, + AchievementId::WeekendWarrior => Achievement { + id: id.clone(), + name: "Weekend Warrior".to_string(), + description: "Coding on a weekend".to_string(), + icon: "โš”๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::DedicatedDeveloper => Achievement { + id: id.clone(), + name: "Dedicated Developer".to_string(), + description: "Coded for 30 days in a row".to_string(), + icon: "๐Ÿ†".to_string(), + unlocked_at: None, + }, + + // Search and exploration + AchievementId::Explorer => Achievement { + id: id.clone(), + name: "Explorer".to_string(), + description: "Used search tools 50 times".to_string(), + icon: "๐Ÿ”".to_string(), + unlocked_at: None, + }, + AchievementId::MasterSearcher => Achievement { + id: id.clone(), + name: "Master Searcher".to_string(), + description: "Searched 500 times across files".to_string(), + icon: "๐Ÿ•ต๏ธโ€โ™€๏ธ".to_string(), + unlocked_at: None, + }, + + // Session achievements + AchievementId::QuickSession => Achievement { + id: id.clone(), + name: "Quick Session".to_string(), + description: "Completed a productive session in under 5 minutes".to_string(), + icon: "โšก".to_string(), + unlocked_at: None, + }, + AchievementId::FocusedWork => Achievement { + id: id.clone(), + name: "Focused Work".to_string(), + description: "Worked for 30 minutes straight".to_string(), + icon: "๐ŸŽฏ".to_string(), + unlocked_at: None, + }, + AchievementId::DeepDive => Achievement { + id: id.clone(), + name: "Deep Dive".to_string(), + description: "Worked for 2 hours continuously".to_string(), + icon: "๐ŸŠโ€โ™€๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::MarathonSession => Achievement { + id: id.clone(), + name: "Marathon Session".to_string(), + description: "5+ hour coding session!".to_string(), + icon: "๐Ÿƒโ€โ™€๏ธ".to_string(), + unlocked_at: None, + }, + + // Special achievements + AchievementId::FirstMessage => Achievement { + id: id.clone(), + name: "First Message".to_string(), + description: "Sent your first message to Hikari".to_string(), + icon: "โœจ".to_string(), + unlocked_at: None, + }, + AchievementId::FirstTool => Achievement { + id: id.clone(), + name: "First Tool".to_string(), + description: "Used your first tool".to_string(), + icon: "๐Ÿ”ง".to_string(), + unlocked_at: None, + }, + AchievementId::FirstCodeBlock => Achievement { + id: id.clone(), + name: "First Code".to_string(), + description: "Generated your first code block".to_string(), + icon: "๐Ÿ“ฆ".to_string(), + unlocked_at: None, + }, + AchievementId::FirstFileEdit => Achievement { + id: id.clone(), + name: "First Edit".to_string(), + description: "Made your first file edit".to_string(), + icon: "โœ๏ธ".to_string(), + unlocked_at: None, + }, + AchievementId::Polyglot => Achievement { + id: id.clone(), + name: "Polyglot".to_string(), + description: "Generated code in 5+ languages in one session".to_string(), + icon: "๐ŸŒ".to_string(), + unlocked_at: None, + }, + AchievementId::SpeedCoder => Achievement { + id: id.clone(), + name: "Speed Coder".to_string(), + description: "Generated 10 code blocks in 10 minutes".to_string(), + icon: "๐Ÿš€".to_string(), + unlocked_at: None, + }, + AchievementId::ClaudeConnoisseur => Achievement { + id: id.clone(), + name: "Claude Connoisseur".to_string(), + description: "Used all available Claude models".to_string(), + icon: "๐ŸŽจ".to_string(), + unlocked_at: None, + }, + AchievementId::MarathonCoder => Achievement { + id: id.clone(), + name: "Marathon Coder".to_string(), + description: "10,000 tokens in a single session".to_string(), + icon: "๐Ÿƒโ€โ™‚๏ธ".to_string(), + unlocked_at: None, + }, + + // Relationship & Greetings + AchievementId::GoodMorning => Achievement { + id: id.clone(), + name: "Good Morning!".to_string(), + description: "Greeted Hikari with a good morning".to_string(), + icon: "๐ŸŒ…".to_string(), + unlocked_at: None, + }, + AchievementId::GoodNight => Achievement { + id: id.clone(), + name: "Good Night".to_string(), + description: "Said good night to Hikari".to_string(), + icon: "๐ŸŒ™".to_string(), + unlocked_at: None, + }, + AchievementId::ThankYou => Achievement { + id: id.clone(), + name: "Grateful Heart".to_string(), + description: "Thanked Hikari for her help".to_string(), + icon: "๐Ÿ’".to_string(), + unlocked_at: None, + }, + AchievementId::LoveYou => Achievement { + id: id.clone(), + name: "Love Connection".to_string(), + description: "Expressed love to Hikari".to_string(), + icon: "๐Ÿ’•".to_string(), + unlocked_at: None, + }, + + // Personality & Fun + AchievementId::EmojiUser => Achievement { + id: id.clone(), + name: "Emoji Enthusiast".to_string(), + description: "Used an emoji in your message".to_string(), + icon: "๐Ÿ˜Š".to_string(), + unlocked_at: None, + }, + AchievementId::QuestionMaster => Achievement { + id: id.clone(), + name: "Question Master".to_string(), + description: "Asked 20 questions".to_string(), + icon: "โ“".to_string(), + unlocked_at: None, + }, + AchievementId::CapsLock => Achievement { + id: id.clone(), + name: "CAPS LOCK ENGAGED".to_string(), + description: "SENT A MESSAGE IN ALL CAPS".to_string(), + icon: "๐Ÿ“ข".to_string(), + unlocked_at: None, + }, + AchievementId::PleaseAndThankYou => Achievement { + id: id.clone(), + name: "Polite Programmer".to_string(), + description: "Said please in a request".to_string(), + icon: "๐ŸŽฉ".to_string(), + unlocked_at: None, + }, + + // Git & Development + AchievementId::GitGuru => Achievement { + id: id.clone(), + name: "Git Guru".to_string(), + description: "Used git commands 10 times".to_string(), + icon: "๐ŸŒฟ".to_string(), + unlocked_at: None, + }, + AchievementId::TestWriter => Achievement { + id: id.clone(), + name: "Test Writer".to_string(), + description: "Created test files".to_string(), + icon: "๐Ÿงช".to_string(), + unlocked_at: None, + }, + AchievementId::Debugger => Achievement { + id: id.clone(), + name: "Bug Squasher".to_string(), + description: "Fixed bugs and errors".to_string(), + icon: "๐Ÿ›".to_string(), + unlocked_at: None, + }, + + // Tool Mastery + AchievementId::BashMaster => Achievement { + id: id.clone(), + name: "Bash Master".to_string(), + description: "Used Bash tool 50 times".to_string(), + icon: "๐Ÿš".to_string(), + unlocked_at: None, + }, + AchievementId::FileExplorer => Achievement { + id: id.clone(), + name: "File Explorer".to_string(), + description: "Read 100 files".to_string(), + icon: "๐Ÿ“‚".to_string(), + unlocked_at: None, + }, + AchievementId::SearchExpert => Achievement { + id: id.clone(), + name: "Search Expert".to_string(), + description: "Used Grep tool 50 times".to_string(), + icon: "๐Ÿ”Ž".to_string(), + unlocked_at: None, + }, + } +} + +// Check achievements based on message content +pub fn check_message_achievements( + message: &str, + progress: &mut AchievementProgress, +) -> Vec { + let mut newly_unlocked = Vec::new(); + let message_lower = message.to_lowercase(); + + println!("Checking message achievements for: {}", message); + + // Relationship & Greetings + if message_lower.contains("good morning") && progress.unlock(AchievementId::GoodMorning) { + newly_unlocked.push(AchievementId::GoodMorning); + } + if (message_lower.contains("good night") || message_lower.contains("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) { + newly_unlocked.push(AchievementId::ThankYou); + } + if (message_lower.contains("love you") || message_lower.contains("ily")) + && progress.unlock(AchievementId::LoveYou) { + newly_unlocked.push(AchievementId::LoveYou); + } + + // Personality & Fun + 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 + && message.chars().any(|c| c.is_alphabetic()) + && progress.unlock(AchievementId::CapsLock) { + newly_unlocked.push(AchievementId::CapsLock); + } + if message_lower.contains("please") && progress.unlock(AchievementId::PleaseAndThankYou) { + newly_unlocked.push(AchievementId::PleaseAndThankYou); + } + + // Git & Development patterns in messages + if (message_lower.contains("fix") || message_lower.contains("bug") || message_lower.contains("error")) + && progress.unlock(AchievementId::Debugger) { + newly_unlocked.push(AchievementId::Debugger); + } + + newly_unlocked +} + +// Check which achievements should be unlocked based on current stats +pub fn check_achievements( + stats: &crate::stats::UsageStats, + progress: &mut AchievementProgress, +) -> Vec { + let mut newly_unlocked = Vec::new(); + + println!("Checking achievements with stats: messages={}, tokens={}, code_blocks={}", + stats.messages_exchanged, + stats.total_input_tokens + stats.total_output_tokens, + stats.code_blocks_generated); + println!("Currently unlocked: {:?}", progress.unlocked); + + // Token milestones + let total_tokens = stats.total_input_tokens + stats.total_output_tokens; + if total_tokens >= 1_000 && progress.unlock(AchievementId::FirstSteps) { + println!("Unlocked FirstSteps achievement!"); + newly_unlocked.push(AchievementId::FirstSteps); + } + if total_tokens >= 10_000 && progress.unlock(AchievementId::GrowingStrong) { + newly_unlocked.push(AchievementId::GrowingStrong); + } + if total_tokens >= 100_000 && progress.unlock(AchievementId::BlossomingCoder) { + newly_unlocked.push(AchievementId::BlossomingCoder); + } + if total_tokens >= 1_000_000 && progress.unlock(AchievementId::TokenMaster) { + newly_unlocked.push(AchievementId::TokenMaster); + } + + // Code generation + if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::HelloWorld) { + newly_unlocked.push(AchievementId::HelloWorld); + } + if stats.code_blocks_generated >= 100 && progress.unlock(AchievementId::CodeWizard) { + newly_unlocked.push(AchievementId::CodeWizard); + } + if stats.code_blocks_generated >= 1000 && progress.unlock(AchievementId::ThousandBlocks) { + newly_unlocked.push(AchievementId::ThousandBlocks); + } + + // File operations + if stats.files_edited >= 10 && progress.unlock(AchievementId::FileManipulator) { + newly_unlocked.push(AchievementId::FileManipulator); + } + let total_files = stats.files_edited + stats.files_created; + if total_files >= 100 && progress.unlock(AchievementId::FileArchitect) { + newly_unlocked.push(AchievementId::FileArchitect); + } + + // Conversation milestones + if stats.messages_exchanged >= 1 && progress.unlock(AchievementId::FirstMessage) { + newly_unlocked.push(AchievementId::FirstMessage); + } + if stats.messages_exchanged >= 10 && progress.unlock(AchievementId::ConversationStarter) { + newly_unlocked.push(AchievementId::ConversationStarter); + } + if stats.messages_exchanged >= 100 && progress.unlock(AchievementId::ChattyKathy) { + newly_unlocked.push(AchievementId::ChattyKathy); + } + if stats.messages_exchanged >= 1000 && progress.unlock(AchievementId::Conversationalist) { + newly_unlocked.push(AchievementId::Conversationalist); + } + + // Tool usage + let unique_tools = stats.tools_usage.len(); + if unique_tools >= 5 && progress.unlock(AchievementId::Toolsmith) { + newly_unlocked.push(AchievementId::Toolsmith); + } + if unique_tools >= 10 && progress.unlock(AchievementId::ToolMaster) { + newly_unlocked.push(AchievementId::ToolMaster); + } + + // Search and exploration + let search_tools = ["Glob", "Grep", "search", "Task"]; + let search_count: u64 = search_tools.iter() + .filter_map(|tool| stats.tools_usage.get(*tool)) + .sum(); + if search_count >= 50 && progress.unlock(AchievementId::Explorer) { + newly_unlocked.push(AchievementId::Explorer); + } + if search_count >= 500 && progress.unlock(AchievementId::MasterSearcher) { + newly_unlocked.push(AchievementId::MasterSearcher); + } + + // Session duration achievements + let session_secs = stats.session_duration_seconds; + 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) { + newly_unlocked.push(AchievementId::FocusedWork); + } + if session_secs >= 7200 && progress.unlock(AchievementId::DeepDive) { + newly_unlocked.push(AchievementId::DeepDive); + } + if session_secs >= 18000 && progress.unlock(AchievementId::MarathonSession) { + newly_unlocked.push(AchievementId::MarathonSession); + } + + // Session token achievement + let session_tokens = stats.session_input_tokens + stats.session_output_tokens; + if session_tokens >= 10000 && progress.unlock(AchievementId::MarathonCoder) { + newly_unlocked.push(AchievementId::MarathonCoder); + } + + // Special first-time achievements + if !stats.tools_usage.is_empty() && progress.unlock(AchievementId::FirstTool) { + newly_unlocked.push(AchievementId::FirstTool); + } + if stats.code_blocks_generated >= 1 && progress.unlock(AchievementId::FirstCodeBlock) { + newly_unlocked.push(AchievementId::FirstCodeBlock); + } + if stats.files_edited >= 1 && progress.unlock(AchievementId::FirstFileEdit) { + newly_unlocked.push(AchievementId::FirstFileEdit); + } + + // Speed coder - need to track time for this + // TODO: Implement tracking for 10 code blocks in 10 minutes + + // Polyglot - need to track languages + // TODO: Implement tracking for multiple programming languages + + // Claude Connoisseur - check model usage + // TODO: Track different Claude models used + + // Tool mastery achievements + if let Some(bash_count) = stats.tools_usage.get("Bash") { + if *bash_count >= 50 && progress.unlock(AchievementId::BashMaster) { + newly_unlocked.push(AchievementId::BashMaster); + } + } + if let Some(read_count) = stats.tools_usage.get("Read") { + if *read_count >= 100 && progress.unlock(AchievementId::FileExplorer) { + newly_unlocked.push(AchievementId::FileExplorer); + } + } + if let Some(grep_count) = stats.tools_usage.get("Grep") { + if *grep_count >= 50 && progress.unlock(AchievementId::SearchExpert) { + newly_unlocked.push(AchievementId::SearchExpert); + } + } + + // Git Guru - check git command usage in Bash + if let Some(bash_count) = stats.tools_usage.get("Bash") { + if *bash_count >= 10 && progress.unlock(AchievementId::GitGuru) { + // TODO: More specific git command tracking + newly_unlocked.push(AchievementId::GitGuru); + } + } + + // Time-based achievements + if let Some(session_start) = progress.session_start { + let hour = session_start.hour(); + let weekday = session_start.weekday(); + + // Early bird - 5 AM to 7 AM + if (5..=7).contains(&hour) && progress.unlock(AchievementId::EarlyBird) { + newly_unlocked.push(AchievementId::EarlyBird); + } + + // Night owl - after midnight + let current_hour = Utc::now().hour(); + if current_hour < 6 && progress.unlock(AchievementId::NightOwl) { + newly_unlocked.push(AchievementId::NightOwl); + } + + // All nighter - 2 AM to 5 AM + if (2..=5).contains(¤t_hour) && progress.unlock(AchievementId::AllNighter) { + newly_unlocked.push(AchievementId::AllNighter); + } + + // Weekend warrior + use chrono::Weekday; + if (weekday == Weekday::Sat || weekday == Weekday::Sun) && progress.unlock(AchievementId::WeekendWarrior) { + newly_unlocked.push(AchievementId::WeekendWarrior); + } + } + + // Dedicated Developer - need to track consecutive days + // TODO: Implement 30 days in a row tracking + + newly_unlocked +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AchievementUnlockedEvent { + pub achievement: Achievement, +} + +// 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())?; + + // 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.save().map_err(|e| e.to_string())?; + + println!("Achievements saved successfully"); + Ok(()) +} + +// Load achievements from persistent store +pub async fn load_achievements(app: &tauri::AppHandle) -> AchievementProgress { + println!("Loading achievements from store..."); + + let store = match app.store("achievements.json") { + Ok(s) => s, + Err(e) => { + println!("Failed to open achievements store: {}", e); + return AchievementProgress::new(); + } + }; + + let mut progress = AchievementProgress::new(); + + // 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()) { + println!("Loaded {} achievements", unlocked_list.len()); + for achievement_id in unlocked_list { + progress.unlocked.insert(achievement_id); + } + } else { + println!("Failed to parse unlocked achievements"); + } + } else { + println!("No unlocked achievements found in store"); + } + + progress +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_achievement_unlock() { + let mut progress = AchievementProgress::new(); + + // First unlock should return true + assert!(progress.unlock(AchievementId::FirstSteps)); + assert!(progress.is_unlocked(&AchievementId::FirstSteps)); + + // Second unlock of same achievement should return false + assert!(!progress.unlock(AchievementId::FirstSteps)); + + // Newly unlocked should contain the achievement + let newly = progress.take_newly_unlocked(); + assert_eq!(newly.len(), 1); + assert_eq!(newly[0], AchievementId::FirstSteps); + + // After taking, newly unlocked should be empty + let newly = progress.take_newly_unlocked(); + assert!(newly.is_empty()); + } +} \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7532af1..1bafa39 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,7 +2,9 @@ use tauri::{AppHandle, State}; use tauri_plugin_store::StoreExt; use crate::config::{ClaudeStartOptions, HikariConfig}; +use crate::stats::UsageStats; use crate::wsl_bridge::SharedBridge; +use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent}; const CONFIG_STORE_KEY: &str = "config"; @@ -72,3 +74,29 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str Ok(()) } + +#[tauri::command] +pub async fn get_usage_stats(bridge: State<'_, SharedBridge>) -> Result { + let bridge = bridge.lock(); + Ok(bridge.get_stats()) +} + +#[tauri::command] +pub async fn load_saved_achievements(app: AppHandle) -> Result, String> { + use chrono::Utc; + + // Load achievements from persistent store + let progress = load_achievements(&app).await; + + // Create events for all previously unlocked achievements + let mut events = Vec::new(); + for achievement_id in &progress.unlocked { + let mut info = get_achievement_info(achievement_id); + info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now + events.push(AchievementUnlockedEvent { + achievement: info, + }); + } + + Ok(events) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 744d378..d7646eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ +mod achievements; mod commands; mod config; mod notifications; +mod stats; mod types; mod wsl_bridge; mod wsl_notifications; @@ -10,6 +12,7 @@ mod windows_toast; use commands::*; use notifications::*; use wsl_bridge::create_shared_bridge; +use commands::load_saved_achievements; use wsl_notifications::*; use vbs_notification::*; use windows_toast::*; @@ -35,6 +38,8 @@ pub fn run() { select_wsl_directory, get_config, save_config, + get_usage_stats, + load_saved_achievements, send_windows_notification, send_simple_notification, send_windows_toast, diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs new file mode 100644 index 0000000..d174bd0 --- /dev/null +++ b/src-tauri/src/stats.rs @@ -0,0 +1,216 @@ +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 { + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub total_cost_usd: f64, + pub session_input_tokens: u64, + pub session_output_tokens: u64, + pub session_cost_usd: f64, + pub model: Option, + + // New fields + pub messages_exchanged: u64, + pub session_messages_exchanged: u64, + pub code_blocks_generated: u64, + pub session_code_blocks_generated: u64, + pub files_edited: u64, + pub session_files_edited: u64, + pub files_created: u64, + pub session_files_created: u64, + pub tools_usage: HashMap, + pub session_tools_usage: HashMap, + pub session_duration_seconds: u64, + #[serde(skip)] + pub session_start: Option, + + // Achievement tracking + #[serde(skip)] + pub achievements: AchievementProgress, +} + +impl UsageStats { + pub fn new() -> Self { + let mut stats = Self::default(); + stats.achievements.start_session(); + stats + } + + pub fn add_usage(&mut self, input_tokens: u64, output_tokens: u64, model: &str) { + self.total_input_tokens += input_tokens; + self.total_output_tokens += output_tokens; + self.session_input_tokens += input_tokens; + self.session_output_tokens += output_tokens; + + let cost = calculate_cost(input_tokens, output_tokens, model); + self.total_cost_usd += cost; + self.session_cost_usd += cost; + + self.model = Some(model.to_string()); + } + + pub fn reset_session(&mut self) { + self.session_input_tokens = 0; + self.session_output_tokens = 0; + self.session_cost_usd = 0.0; + self.session_messages_exchanged = 0; + self.session_code_blocks_generated = 0; + self.session_files_edited = 0; + self.session_files_created = 0; + self.session_tools_usage.clear(); + self.session_duration_seconds = 0; + self.session_start = Some(Instant::now()); + self.achievements.start_session(); + } + + pub fn increment_messages(&mut self) { + self.messages_exchanged += 1; + self.session_messages_exchanged += 1; + } + + pub fn increment_code_blocks(&mut self) { + self.code_blocks_generated += 1; + self.session_code_blocks_generated += 1; + } + + pub fn increment_files_edited(&mut self) { + self.files_edited += 1; + self.session_files_edited += 1; + } + + pub fn increment_files_created(&mut self) { + self.files_created += 1; + self.session_files_created += 1; + } + + 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; + } + + pub fn get_session_duration(&mut self) -> u64 { + // Only update if more than 1 second has passed to reduce calculations + if let Some(start) = self.session_start { + let elapsed = start.elapsed().as_secs(); + if elapsed > self.session_duration_seconds { + self.session_duration_seconds = elapsed; + } + } + self.session_duration_seconds + } + + pub fn check_achievements(&mut self) -> Vec { + let stats_copy = UsageStats { + total_input_tokens: self.total_input_tokens, + total_output_tokens: self.total_output_tokens, + total_cost_usd: self.total_cost_usd, + session_input_tokens: self.session_input_tokens, + session_output_tokens: self.session_output_tokens, + session_cost_usd: self.session_cost_usd, + model: self.model.clone(), + messages_exchanged: self.messages_exchanged, + session_messages_exchanged: self.session_messages_exchanged, + code_blocks_generated: self.code_blocks_generated, + session_code_blocks_generated: self.session_code_blocks_generated, + files_edited: self.files_edited, + session_files_edited: self.session_files_edited, + files_created: self.files_created, + session_files_created: self.session_files_created, + tools_usage: self.tools_usage.clone(), + session_tools_usage: self.session_tools_usage.clone(), + session_duration_seconds: self.session_duration_seconds, + session_start: self.session_start, + achievements: AchievementProgress::new(), // Dummy for copy + }; + check_achievements(&stats_copy, &mut self.achievements) + } +} + +// Pricing as of January 2025 +// https://www.anthropic.com/pricing +fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 { + let (input_price_per_million, output_price_per_million) = match model { + // Opus 4.5 + "claude-opus-4-5-20251101" => (15.0, 75.0), + + // Opus 4 + "claude-opus-4-20250514" => (15.0, 75.0), + + // Sonnet 4 + "claude-sonnet-4-20250514" => (3.0, 15.0), + + // Previous generation models + "claude-3-5-sonnet-20241022" => (3.0, 15.0), + "claude-3-5-sonnet-20240620" => (3.0, 15.0), + "claude-3-5-haiku-20241022" => (1.0, 5.0), + "claude-3-opus-20240229" => (15.0, 75.0), + "claude-3-sonnet-20240229" => (3.0, 15.0), + "claude-3-haiku-20240307" => (0.25, 1.25), + + // Default to Sonnet pricing if model unknown + _ => (3.0, 15.0), + }; + + let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million; + let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million; + + input_cost + output_cost +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatsUpdateEvent { + pub stats: UsageStats, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cost_calculation_sonnet() { + let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514"); + // 1000 input * $3/M = $0.003 + // 2000 output * $15/M = $0.030 + // Total = $0.033 + assert!((cost - 0.033).abs() < 0.0001); + } + + #[test] + fn test_cost_calculation_opus() { + let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514"); + // 1000 input * $15/M = $0.015 + // 2000 output * $75/M = $0.150 + // Total = $0.165 + assert!((cost - 0.165).abs() < 0.0001); + } + + #[test] + fn test_usage_stats_accumulation() { + let mut stats = UsageStats::new(); + stats.add_usage(1000, 2000, "claude-sonnet-4-20250514"); + + assert_eq!(stats.total_input_tokens, 1000); + assert_eq!(stats.total_output_tokens, 2000); + assert_eq!(stats.session_input_tokens, 1000); + assert_eq!(stats.session_output_tokens, 2000); + assert!((stats.total_cost_usd - 0.033).abs() < 0.0001); + } + + #[test] + fn test_session_reset() { + let mut stats = UsageStats::new(); + stats.add_usage(1000, 2000, "claude-sonnet-4-20250514"); + stats.reset_session(); + + assert_eq!(stats.total_input_tokens, 1000); + assert_eq!(stats.total_output_tokens, 2000); + assert_eq!(stats.session_input_tokens, 0); + assert_eq!(stats.session_output_tokens, 0); + 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/types.rs b/src-tauri/src/types.rs index 108804e..ddcf67c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -1,5 +1,11 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageInfo { + pub input_tokens: u64, + pub output_tokens: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum CharacterState { @@ -87,6 +93,8 @@ pub enum ClaudeMessage { num_turns: Option, #[serde(default)] permission_denials: Option>, + #[serde(default)] + usage: Option, }, } @@ -97,6 +105,8 @@ pub struct AssistantMessageContent { pub model: Option, #[serde(default)] pub stop_reason: Option, + #[serde(default)] + pub usage: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 09b9bcb..b3744f2 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -10,7 +10,10 @@ use tempfile::NamedTempFile; 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}; +use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; @@ -72,6 +75,7 @@ pub struct WslBridge { working_directory: String, session_id: Option, mcp_config_file: Option, + stats: Arc>, } impl WslBridge { @@ -82,14 +86,37 @@ impl WslBridge { working_directory: String::new(), session_id: None, mcp_config_file: None, + stats: Arc::new(RwLock::new(UsageStats::new())), } } + #[allow(dead_code)] + pub async fn new_with_loaded_achievements(app: &tauri::AppHandle) -> Self { + let bridge = Self::new(); + + // Load saved achievements into the stats + let achievements = crate::achievements::load_achievements(app).await; + println!("Loaded achievements into bridge: {} unlocked", achievements.unlocked.len()); + bridge.stats.write().achievements = achievements; + + bridge + } + pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { if self.process.is_some() { return Err("Process already running".to_string()); } + // Load saved achievements when starting a new session + let app_clone = app.clone(); + let stats = self.stats.clone(); + 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()); + stats.write().achievements = achievements; + }); + let working_dir = &options.working_dir; self.working_directory = working_dir.clone(); @@ -249,10 +276,22 @@ impl WslBridge { self.stdin = stdin; self.process = Some(child); + // Reset session stats when starting new session + self.stats.write().reset_session(); + + // Load saved achievements + let app_handle = app.clone(); + let stats_clone = self.stats.clone(); + tokio::spawn(async move { + let saved_progress = crate::achievements::load_achievements(&app_handle).await; + stats_clone.write().achievements = saved_progress; + }); + if let Some(stdout) = stdout { let app_clone = app.clone(); + let stats_clone = self.stats.clone(); thread::spawn(move || { - handle_stdout(stdout, app_clone); + handle_stdout(stdout, app_clone, stats_clone); }); } @@ -311,6 +350,20 @@ impl WslBridge { pub fn get_working_directory(&self) -> &str { &self.working_directory } + + pub fn get_stats(&self) -> UsageStats { + self.stats.read().clone() + } + + #[allow(dead_code)] + pub fn update_stats(&mut self, input_tokens: u64, output_tokens: u64, model: &str) { + self.stats.write().add_usage(input_tokens, output_tokens, model); + } + + #[allow(dead_code)] + pub fn reset_session_stats(&mut self) { + self.stats.write().reset_session(); + } } impl Default for WslBridge { @@ -319,13 +372,13 @@ impl Default for WslBridge { } } -fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) { +fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>) { let reader = BufReader::new(stdout); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { - if let Err(e) = process_json_line(&line, &app) { + if let Err(e) = process_json_line(&line, &app, &stats) { eprintln!("Error processing line: {}", e); } } @@ -358,7 +411,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) { } } -fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> { +fn process_json_line(line: &str, app: &AppHandle, stats: &Arc>) -> Result<(), String> { let message: ClaudeMessage = serde_json::from_str(line) .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; @@ -379,12 +432,47 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> { let mut state = CharacterState::Typing; let mut tool_name = None; + // Only update stats if we have usage information + if let Some(usage) = &message.usage { + if let Some(model) = &message.model { + // Batch all stats updates in a single write lock + { + let mut stats_guard = stats.write(); + stats_guard.increment_messages(); + stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model); + stats_guard.get_session_duration(); + } + + // Don't emit here - we'll emit on Result message instead + // This reduces the frequency of updates + } else { + // Just increment message count if no usage info + stats.write().increment_messages(); + } + } else { + // Just increment message count if no usage info + stats.write().increment_messages(); + } + for block in &message.content { match block { ContentBlock::ToolUse { name, input, .. } => { tool_name = Some(name.clone()); state = get_tool_state(name); + // Batch tool tracking updates + { + let mut stats_guard = stats.write(); + stats_guard.increment_tool_usage(name); + + // Track file operations + match name.as_str() { + "Edit" => stats_guard.increment_files_edited(), + "Write" => stats_guard.increment_files_created(), + _ => {} + } + } + let desc = format_tool_description(name, input); let _ = app.emit("claude:output", OutputEvent { line_type: "tool".to_string(), @@ -393,6 +481,12 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> { }); } ContentBlock::Text { text } => { + // Count code blocks in the text + let code_blocks = text.matches("```").count() / 2; + for _ in 0..code_blocks { + stats.write().increment_code_blocks(); + } + let _ = app.emit("claude:output", OutputEvent { line_type: "assistant".to_string(), content: text.clone(), @@ -440,13 +534,55 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> { } } - ClaudeMessage::Result { subtype, result, permission_denials, .. } => { + ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => { let state = if subtype == "success" { CharacterState::Success } else { CharacterState::Error }; + // Always emit updated stats on result message (less frequent) + // This includes the latest session duration + let newly_unlocked = { + let mut stats_guard = stats.write(); + stats_guard.get_session_duration(); + println!("Checking achievements after result message..."); + let unlocked = stats_guard.check_achievements(); + println!("Newly unlocked achievements: {:?}", unlocked); + unlocked + }; + + // 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, + }); + } + + // Save achievements after unlocking new ones + if !newly_unlocked.is_empty() { + println!("Saving newly unlocked achievements: {:?}", newly_unlocked); + let app_handle = app.clone(); + let achievements_progress = stats.read().achievements.clone(); + + // 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 { + eprintln!("Failed to save achievements: {}", e); + } else { + println!("Achievement save task completed successfully"); + } + }); + } + + let current_stats = stats.read().clone(); + let stats_event = StatsUpdateEvent { + stats: current_stats, + }; + let _ = app.emit("claude:stats", stats_event); + // Only emit error results - success content is already sent via Assistant message if subtype != "success" { if let Some(text) = result { @@ -480,7 +616,60 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> { emit_state_change(app, state, None); } - ClaudeMessage::User { .. } => { + ClaudeMessage::User { message } => { + // Increment message count for user messages + stats.write().increment_messages(); + + // Extract text content from the message + let message_text = message.content.iter() + .filter_map(|block| match block { + crate::types::ContentBlock::Text { text } => Some(text.clone()), + _ => None, + }) + .collect::>() + .join(" "); + + // Check achievements after user message + let newly_unlocked = { + let mut stats_guard = stats.write(); + println!("User sent message, checking achievements..."); + + // Check message-based achievements + let mut unlocked = crate::achievements::check_message_achievements( + &message_text, + &mut stats_guard.achievements, + ); + + // Check stats-based achievements + let stats_unlocked = stats_guard.check_achievements(); + unlocked.extend(stats_unlocked); + + unlocked + }; + + // Emit achievement events for any newly unlocked achievements + 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, + }); + } + + // Save achievements after unlocking new ones + if !newly_unlocked.is_empty() { + println!("Saving newly unlocked achievements from user message"); + 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 { + eprintln!("Failed to save achievements: {}", e); + } else { + println!("Achievements saved after user message"); + } + }); + } + emit_state_change(app, CharacterState::Thinking, None); } } diff --git a/src/app.css b/src/app.css index 7e6aa5c..5693b71 100644 --- a/src/app.css +++ b/src/app.css @@ -10,7 +10,14 @@ --accent-secondary: #ff6b9d; --text-primary: #ffffff; --text-secondary: #a0a0a0; + --text-tertiary: #6b7280; --border-color: #2a2a4a; + + /* Terminal specific colors */ + --terminal-user: #22d3ee; + --terminal-tool: #c084fc; + --terminal-tool-name: #ddd6fe; + --terminal-error: #f87171; } [data-theme="light"] { @@ -22,7 +29,14 @@ --accent-secondary: #ff6b9d; --text-primary: #1a1a2e; --text-secondary: #5a5a7a; + --text-tertiary: #9ca3af; --border-color: #d0d0e0; + + /* Terminal specific colors */ + --terminal-user: #0891b2; + --terminal-tool: #7c3aed; + --terminal-tool-name: #8b5cf6; + --terminal-error: #dc2626; } html, diff --git a/src/lib/components/AchievementNotification.svelte b/src/lib/components/AchievementNotification.svelte new file mode 100644 index 0000000..b1c9712 --- /dev/null +++ b/src/lib/components/AchievementNotification.svelte @@ -0,0 +1,202 @@ + + +{#if showNotification && currentAchievement} +
+ +
+ +
+ + +
+ + +
+ +
+
{currentAchievement.achievement.icon}
+ + +
โœจ
+
+ โœจ +
+
+ โœจ +
+
+ + +
+

+ Achievement Unlocked! +

+

+ {currentAchievement.achievement.name} +

+

+ {currentAchievement.achievement.description} +

+ + +
+ + {getAchievementRarity(currentAchievement.achievement.id)} + +
+
+
+ + +
+ {#each Array(10) as _ (_)} +
+ {/each} +
+
+
+
+{/if} + + diff --git a/src/lib/components/AchievementsPanel.svelte b/src/lib/components/AchievementsPanel.svelte new file mode 100644 index 0000000..a2f75c5 --- /dev/null +++ b/src/lib/components/AchievementsPanel.svelte @@ -0,0 +1,266 @@ + + + +{#if isOpen} +
e.key === "Escape" && onClose?.()} + role="button" + tabindex="-1" + aria-label="Close achievements panel" + transition:slide={{ duration: 300, easing: quintOut }} + >
+ +
+ +
+
+

Achievements

+ +
+ + +
+
+ {progress.unlocked} / {progress.total} Unlocked + {progress.percentage}% +
+
+
+
+
+
+ + +
+ {#each achievementCategories as category (category.name)} + {@const achievements = getAchievementsForCategory(category.ids)} + {@const unlockedCount = achievements.filter((a) => a.unlocked).length} + +
+ + + {#if selectedCategory === category.name} +
+ {#each achievements as achievement (achievement.id)} +
+
+ +
+ {achievement.icon} +
+ + +
+
+

+ {achievement.name} +

+ + {achievement.rarity} + +
+

+ {achievement.description} +

+ + {#if achievement.unlocked && achievement.unlockedAt} +

+ Unlocked {formatDate(achievement.unlockedAt)} +

+ {:else if achievement.maxProgress && achievement.progress !== undefined} + +
+
+ Progress + {achievement.progress} / {achievement.maxProgress} +
+
+
+
+
+ {/if} +
+
+
+ {/each} +
+ {/if} +
+ {/each} +
+ + + {#if achievementsState.lastUnlocked} +
+

Last Unlocked:

+
+ {achievementsState.lastUnlocked.icon} +
+

+ {achievementsState.lastUnlocked.name} +

+

+ {formatDate(achievementsState.lastUnlocked.unlockedAt)} +

+
+
+
+ {/if} +
+{/if} + + diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 025ca8d..3517c19 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -54,7 +54,7 @@ disabled={!isConnected || isSubmitting} rows={1} class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] - rounded-lg text-white placeholder-gray-500 resize-none + rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200" diff --git a/src/lib/components/StatsDisplay.svelte b/src/lib/components/StatsDisplay.svelte new file mode 100644 index 0000000..705433d --- /dev/null +++ b/src/lib/components/StatsDisplay.svelte @@ -0,0 +1,168 @@ + + +
+
+ Duration: + {$formattedStats.sessionDuration} +
+ +
+ Messages: + {$formattedStats.messagesSession} + / {$formattedStats.messagesTotal} +
+ +
+

Tokens & Cost

+
+ Session: + {$formattedStats.sessionTokens} + {$formattedStats.sessionCost} +
+
+ Input: + {$formattedStats.sessionInputTokens} +
+
+ Output: + {$formattedStats.sessionOutputTokens} +
+
+ Total: + {$formattedStats.totalTokens} + {$formattedStats.totalCost} +
+
+ +
+

Activity

+
+ Code blocks: + {$formattedStats.codeBlocksSession} + / {$formattedStats.codeBlocksTotal} +
+
+ Files edited: + {$formattedStats.filesEditedSession} + / {$formattedStats.filesEditedTotal} +
+
+ Files created: + {$formattedStats.filesCreatedSession} + / {$formattedStats.filesCreatedTotal} +
+
+ + {#if Object.keys($formattedStats.sessionToolsUsage).length > 0} +
+

+ +

+ {#if showToolsBreakdown} +
+ {#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)} +
+ {tool}: + {count} +
+ {/each} +
+ {/if} +
+ {/if} + +
+ Model: + {$formattedStats.model} +
+
+ + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 2d3b7b2..e928349 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -1,4 +1,10 @@
+ +
+ +{#if showStats} + + +
(showStats = false)}>
+
+ +
+{/if} diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index e739a44..cd432f1 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -25,17 +25,17 @@ function getLineClass(type: string): string { switch (type) { case "user": - return "text-cyan-400"; + return "terminal-user"; case "assistant": - return "text-gray-100"; + return "terminal-assistant"; case "system": - return "text-gray-500 italic"; + return "terminal-system italic"; case "tool": - return "text-purple-400"; + return "terminal-tool"; case "error": - return "text-red-400"; + return "terminal-error"; default: - return "text-gray-300"; + return "terminal-default"; } } @@ -75,7 +75,7 @@
- Terminal + Terminal
{#if lines.length === 0} -
Waiting for Claude... Type a message below to start!
+
+ Waiting for Claude... Type a message below to start! +
{:else} {#each lines as line (line.id)}
- {formatTime(line.timestamp)} + {formatTime(line.timestamp)} {#if getLinePrefix(line.type)} - {getLinePrefix(line.type)} + {getLinePrefix(line.type)} {/if} {#if line.toolName} - [{line.toolName}] + [{line.toolName}] {/if} {line.content}
@@ -107,4 +109,49 @@ scrollbar-width: thin; scrollbar-color: var(--border-color) var(--bg-terminal); } + + /* Terminal text colors that adapt to theme */ + .terminal-user { + color: var(--terminal-user, #22d3ee); + } + + .terminal-assistant { + color: var(--text-primary); + } + + .terminal-system { + color: var(--text-secondary); + } + + .terminal-tool { + color: var(--terminal-tool, #c084fc); + } + + .terminal-error { + color: var(--terminal-error, #f87171); + } + + .terminal-default { + color: var(--text-primary); + } + + .terminal-timestamp { + color: var(--text-tertiary, #6b7280); + } + + .terminal-prefix { + color: var(--text-secondary); + } + + .terminal-tool-name { + color: var(--terminal-tool-name, #ddd6fe); + } + + .terminal-waiting { + color: var(--text-secondary); + } + + .terminal-header-text { + color: var(--text-secondary); + } diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index ceeae60..6fda8a2 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -4,6 +4,7 @@ export enum NotificationType { PERMISSION = "permission", CONNECTION = "connection", TASK_START = "task_start", + ACHIEVEMENT = "achievement", } export interface NotificationSound { @@ -45,4 +46,10 @@ export const NOTIFICATION_SOUNDS: Record = phrase: "Working on it!", volume: 0.6, }, + [NotificationType.ACHIEVEMENT]: { + type: NotificationType.ACHIEVEMENT, + filename: "achievement.mp3", + phrase: "Achievement Get~!", + volume: 0.8, + }, }; diff --git a/src/lib/sounds/achievement.ts b/src/lib/sounds/achievement.ts new file mode 100644 index 0000000..7826c82 --- /dev/null +++ b/src/lib/sounds/achievement.ts @@ -0,0 +1,19 @@ +// Achievement sound player using the notification system + +import { soundPlayer } from "$lib/notifications"; +import { NotificationType } from "$lib/notifications/types"; + +export function playAchievementSound() { + // Use the soundPlayer which respects global notification settings + soundPlayer.play(NotificationType.ACHIEVEMENT); +} + +// Test function for development +export function testAchievementSound() { + try { + playAchievementSound(); + console.log("Achievement sound played successfully!"); + } catch (error) { + console.error("Error playing achievement sound:", error); + } +} diff --git a/src/lib/stores/achievements.ts b/src/lib/stores/achievements.ts new file mode 100644 index 0000000..d749e6c --- /dev/null +++ b/src/lib/stores/achievements.ts @@ -0,0 +1,649 @@ +import { writable, derived } from "svelte/store"; +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; +import type { Achievement, AchievementUnlockedEvent, AchievementId } from "$lib/types/achievements"; +import { playAchievementSound } from "$lib/sounds/achievement"; + +interface AchievementState { + achievements: Record; + totalUnlocked: number; + lastUnlocked: Achievement | null; +} + +// Initial achievement definitions +const achievementDefinitions: Record< + AchievementId, + Omit +> = { + // Token milestones + FirstSteps: { + id: "FirstSteps", + name: "First Steps", + description: "Generated your first 1,000 tokens", + icon: "๐Ÿ‘ถ", + rarity: "common", + maxProgress: 1000, + }, + GrowingStrong: { + id: "GrowingStrong", + name: "Growing Strong", + description: "Reached 10,000 tokens total", + icon: "๐ŸŒฑ", + rarity: "common", + maxProgress: 10000, + }, + BlossomingCoder: { + id: "BlossomingCoder", + name: "Blossoming Coder", + description: "Generated 100,000 tokens - you're really growing!", + icon: "๐ŸŒธ", + rarity: "rare", + maxProgress: 100000, + }, + TokenMaster: { + id: "TokenMaster", + name: "Token Master", + description: "One million tokens! You're unstoppable!", + icon: "๐Ÿ‘‘", + rarity: "legendary", + maxProgress: 1000000, + }, + + // Code generation + HelloWorld: { + id: "HelloWorld", + name: "Hello, World!", + description: "Generated your first code block", + icon: "๐Ÿ‘‹", + rarity: "common", + maxProgress: 1, + }, + CodeWizard: { + id: "CodeWizard", + name: "Code Wizard", + description: "100 code blocks generated", + icon: "๐Ÿง™โ€โ™€๏ธ", + rarity: "rare", + maxProgress: 100, + }, + ThousandBlocks: { + id: "ThousandBlocks", + name: "Thousand Blocks", + description: "1,000 code blocks! You're a code machine!", + icon: "๐Ÿ—๏ธ", + rarity: "epic", + maxProgress: 1000, + }, + + // File operations + FileManipulator: { + id: "FileManipulator", + name: "File Manipulator", + description: "Edited 10 files", + icon: "๐Ÿ“", + rarity: "common", + maxProgress: 10, + }, + FileArchitect: { + id: "FileArchitect", + name: "File Architect", + description: "Created or edited 100 files", + icon: "๐Ÿ›๏ธ", + rarity: "rare", + maxProgress: 100, + }, + + // Conversation milestones + ConversationStarter: { + id: "ConversationStarter", + name: "Conversation Starter", + description: "Exchanged 10 messages", + icon: "๐Ÿ’ฌ", + rarity: "common", + maxProgress: 10, + }, + ChattyKathy: { + id: "ChattyKathy", + name: "Chatty Kathy", + description: "100 messages exchanged", + icon: "๐Ÿ—ฃ๏ธ", + rarity: "common", + maxProgress: 100, + }, + Conversationalist: { + id: "Conversationalist", + name: "Master Conversationalist", + description: "1,000 messages! We're really connecting!", + icon: "๐Ÿ’–", + rarity: "rare", + maxProgress: 1000, + }, + + // Tool usage + Toolsmith: { + id: "Toolsmith", + name: "Toolsmith", + description: "Used 5 different tools", + icon: "๐Ÿ”จ", + rarity: "common", + maxProgress: 5, + }, + ToolMaster: { + id: "ToolMaster", + name: "Tool Master", + description: "Used 10 different tools efficiently", + icon: "๐Ÿ› ๏ธ", + rarity: "rare", + maxProgress: 10, + }, + + // Time-based achievements + EarlyBird: { + id: "EarlyBird", + name: "Early Bird", + description: "Started a session between 5 AM and 7 AM", + icon: "๐ŸŒ…", + rarity: "common", + }, + NightOwl: { + id: "NightOwl", + name: "Night Owl", + description: "Coding after midnight", + icon: "๐Ÿฆ‰", + rarity: "common", + }, + AllNighter: { + id: "AllNighter", + name: "All Nighter", + description: "Worked through the night (2 AM - 5 AM)", + icon: "๐ŸŒ™", + rarity: "rare", + }, + WeekendWarrior: { + id: "WeekendWarrior", + name: "Weekend Warrior", + description: "Coding on a weekend", + icon: "โš”๏ธ", + rarity: "common", + }, + DedicatedDeveloper: { + id: "DedicatedDeveloper", + name: "Dedicated Developer", + description: "Coded for 30 days in a row", + icon: "๐Ÿ†", + rarity: "legendary", + }, + + // Search and exploration + Explorer: { + id: "Explorer", + name: "Explorer", + description: "Used search tools 50 times", + icon: "๐Ÿ”", + rarity: "common", + maxProgress: 50, + }, + MasterSearcher: { + id: "MasterSearcher", + name: "Master Searcher", + description: "Searched 500 times across files", + icon: "๐Ÿ•ต๏ธโ€โ™€๏ธ", + rarity: "rare", + maxProgress: 500, + }, + + // Session achievements + QuickSession: { + id: "QuickSession", + name: "Quick Session", + description: "Completed a productive session in under 5 minutes", + icon: "โšก", + rarity: "common", + }, + FocusedWork: { + id: "FocusedWork", + name: "Focused Work", + description: "Worked for 30 minutes straight", + icon: "๐ŸŽฏ", + rarity: "common", + }, + DeepDive: { + id: "DeepDive", + name: "Deep Dive", + description: "Worked for 2 hours continuously", + icon: "๐ŸŠโ€โ™€๏ธ", + rarity: "rare", + }, + MarathonSession: { + id: "MarathonSession", + name: "Marathon Session", + description: "5+ hour coding session!", + icon: "๐Ÿƒโ€โ™€๏ธ", + rarity: "epic", + }, + + // Special achievements + FirstMessage: { + id: "FirstMessage", + name: "First Message", + description: "Sent your first message to Hikari", + icon: "โœจ", + rarity: "common", + maxProgress: 1, + }, + FirstTool: { + id: "FirstTool", + name: "First Tool", + description: "Used your first tool", + icon: "๐Ÿ”ง", + rarity: "common", + maxProgress: 1, + }, + FirstCodeBlock: { + id: "FirstCodeBlock", + name: "First Code", + description: "Generated your first code block", + icon: "๐Ÿ“ฆ", + rarity: "common", + maxProgress: 1, + }, + FirstFileEdit: { + id: "FirstFileEdit", + name: "First Edit", + description: "Made your first file edit", + icon: "โœ๏ธ", + rarity: "common", + maxProgress: 1, + }, + Polyglot: { + id: "Polyglot", + name: "Polyglot", + description: "Generated code in 5+ languages in one session", + icon: "๐ŸŒ", + rarity: "rare", + maxProgress: 5, + }, + SpeedCoder: { + id: "SpeedCoder", + name: "Speed Coder", + description: "Generated 10 code blocks in 10 minutes", + icon: "๐Ÿš€", + rarity: "rare", + }, + ClaudeConnoisseur: { + id: "ClaudeConnoisseur", + name: "Claude Connoisseur", + description: "Used all available Claude models", + icon: "๐ŸŽจ", + rarity: "epic", + maxProgress: 5, // Adjust based on available models + }, + MarathonCoder: { + id: "MarathonCoder", + name: "Marathon Coder", + description: "10,000 tokens in a single session", + icon: "๐Ÿƒโ€โ™‚๏ธ", + rarity: "epic", + maxProgress: 10000, + }, + + // Relationship & Greetings + GoodMorning: { + id: "GoodMorning", + name: "Good Morning!", + description: "Greeted Hikari with a good morning", + icon: "๐ŸŒ…", + rarity: "common", + maxProgress: 1, + }, + GoodNight: { + id: "GoodNight", + name: "Sweet Dreams", + description: "Said good night to Hikari", + icon: "๐ŸŒ™", + rarity: "common", + maxProgress: 1, + }, + ThankYou: { + id: "ThankYou", + name: "Grateful Heart", + description: "Thanked Hikari for her help", + icon: "๐Ÿ™", + rarity: "common", + maxProgress: 1, + }, + LoveYou: { + id: "LoveYou", + name: "Heartfelt", + description: "Expressed love to Hikari", + icon: "๐Ÿ’•", + rarity: "rare", + maxProgress: 1, + }, + + // Personality & Fun + EmojiUser: { + id: "EmojiUser", + name: "Emoji Master", + description: "Used 20+ emojis in messages", + icon: "๐Ÿ˜„", + rarity: "common", + maxProgress: 20, + }, + CapsLock: { + id: "CapsLock", + name: "CAPS LOCK", + description: "SENT A MESSAGE IN ALL CAPS", + icon: "๐Ÿ”Š", + rarity: "common", + maxProgress: 1, + }, + QuestionMaster: { + id: "QuestionMaster", + name: "Question Master", + description: "Asked 50 questions", + icon: "โ“", + rarity: "common", + maxProgress: 50, + }, + PleaseAndThankYou: { + id: "PleaseAndThankYou", + name: "Polite Programmer", + description: "Always says please and thank you", + icon: "๐ŸŽฉ", + rarity: "common", + maxProgress: 10, + }, + + // Git & Development + CommitMaster: { + id: "CommitMaster", + name: "Commit Master", + description: "Made 100 commits through Hikari", + icon: "๐Ÿ“", + rarity: "rare", + maxProgress: 100, + }, + PRO: { + id: "PRO", + name: "PRO", + description: "Created 10 pull requests", + icon: "๐Ÿ”€", + rarity: "rare", + maxProgress: 10, + }, + Reviewer: { + id: "Reviewer", + name: "Code Reviewer", + description: "Reviewed 10 pull requests", + icon: "๐Ÿ‘€", + rarity: "rare", + maxProgress: 10, + }, + IssueTracker: { + id: "IssueTracker", + name: "Issue Tracker", + description: "Created 25 issues", + icon: "๐ŸŽฏ", + rarity: "rare", + maxProgress: 25, + }, + GitGuru: { + id: "GitGuru", + name: "Git Guru", + description: "Mastered git operations", + icon: "๐ŸŒฒ", + rarity: "epic", + }, + + // Tool Mastery + BashMaster: { + id: "BashMaster", + name: "Bash Master", + description: "Used bash commands 100 times", + icon: "๐Ÿ’ป", + rarity: "rare", + maxProgress: 100, + }, + FileExplorer: { + id: "FileExplorer", + name: "File Explorer", + description: "Explored files 100 times", + icon: "๐Ÿ“‚", + rarity: "common", + maxProgress: 100, + }, + SearchExpert: { + id: "SearchExpert", + name: "Search Expert", + description: "Mastered advanced search queries", + icon: "๐Ÿ”Ž", + rarity: "rare", + }, + AgentCommander: { + id: "AgentCommander", + name: "Agent Commander", + description: "Used task agents effectively", + icon: "๐Ÿค–", + rarity: "rare", + }, + MCPMaster: { + id: "MCPMaster", + name: "MCP Master", + description: "Mastered MCP tool usage", + icon: "๐Ÿ› ๏ธ", + rarity: "epic", + }, +}; + +// Initialize all achievements as locked +const initialAchievements: Record = {} as Record< + AchievementId, + Achievement +>; +for (const [id, def] of Object.entries(achievementDefinitions)) { + initialAchievements[id as AchievementId] = { + ...def, + unlocked: false, + progress: 0, + }; +} + +// Create the main store +function createAchievementsStore() { + const { subscribe, update } = writable({ + achievements: initialAchievements, + totalUnlocked: 0, + lastUnlocked: null, + }); + + return { + subscribe, + unlockAchievement: (event: AchievementUnlockedEvent, playSound: boolean = true) => { + update((state) => { + const achievement = state.achievements[event.achievement.id]; + if (achievement && !achievement.unlocked) { + achievement.unlocked = true; + achievement.unlockedAt = event.achievement.unlocked_at + ? new Date(event.achievement.unlocked_at) + : new Date(); + state.totalUnlocked++; + state.lastUnlocked = achievement; + + // Play achievement sound only for new unlocks, not when loading saved ones + if (playSound) { + try { + playAchievementSound(); + } catch (error) { + console.error("Failed to play achievement sound:", error); + } + } + } + return state; + }); + }, + updateProgress: (id: AchievementId, progress: number) => { + update((state) => { + const achievement = state.achievements[id]; + if (achievement) { + achievement.progress = progress; + } + return state; + }); + }, + reset: () => { + update(() => ({ + achievements: initialAchievements, + totalUnlocked: 0, + lastUnlocked: null, + })); + }, + }; +} + +export const achievementsStore = createAchievementsStore(); + +// Derived stores for different views +export const unlockedAchievements = derived(achievementsStore, ($store) => + Object.values($store.achievements).filter((a) => a.unlocked) +); + +export const lockedAchievements = derived(achievementsStore, ($store) => + Object.values($store.achievements).filter((a) => !a.unlocked) +); + +export const achievementsByRarity = derived(achievementsStore, ($store) => { + const byRarity: Record = { + common: [], + rare: [], + epic: [], + legendary: [], + }; + + for (const achievement of Object.values($store.achievements)) { + byRarity[achievement.rarity].push(achievement); + } + + return byRarity; +}); + +export const achievementProgress = derived(achievementsStore, ($store) => ({ + unlocked: $store.totalUnlocked, + total: Object.keys($store.achievements).length, + percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100), +})); + +// Initialize achievement listener +export async function initAchievementsListener() { + // Listen for achievement unlocked events + await listen("achievement:unlocked", (event) => { + achievementsStore.unlockAchievement(event.payload); + }); + + // Load saved achievements from persistent storage + try { + const savedAchievements = await invoke("load_saved_achievements"); + + // Update the store with saved achievements (don't play sounds) + for (const event of savedAchievements) { + achievementsStore.unlockAchievement(event, false); + } + } catch (error) { + console.error("Failed to load saved achievements:", error); + } +} + +// Export achievement categories for the display panel +export const achievementCategories = [ + { + name: "Token Milestones", + description: "Track your token generation progress", + ids: ["FirstSteps", "GrowingStrong", "BlossomingCoder", "TokenMaster"] as AchievementId[], + }, + { + name: "Code Generation", + description: "Achievements for generating code", + ids: ["HelloWorld", "CodeWizard", "ThousandBlocks"] as AchievementId[], + }, + { + name: "File Operations", + description: "Working with files and projects", + ids: ["FileManipulator", "FileArchitect"] as AchievementId[], + }, + { + name: "Conversations", + description: "Building our relationship through chat", + ids: ["ConversationStarter", "ChattyKathy", "Conversationalist"] as AchievementId[], + }, + { + name: "Tools & Skills", + description: "Mastering different tools", + ids: ["Toolsmith", "ToolMaster"] as AchievementId[], + }, + { + name: "Time-Based", + description: "When you code matters too!", + ids: [ + "EarlyBird", + "NightOwl", + "AllNighter", + "WeekendWarrior", + "DedicatedDeveloper", + ] as AchievementId[], + }, + { + name: "Search & Explore", + description: "Finding what you need", + ids: ["Explorer", "MasterSearcher"] as AchievementId[], + }, + { + name: "Session Records", + description: "Your coding session achievements", + ids: [ + "QuickSession", + "FocusedWork", + "DeepDive", + "MarathonSession", + "MarathonCoder", + ] as AchievementId[], + }, + { + name: "Relationship & Greetings", + description: "Our special moments together", + ids: ["GoodMorning", "GoodNight", "ThankYou", "LoveYou"] as AchievementId[], + }, + { + name: "Personality & Fun", + description: "Express yourself!", + ids: ["EmojiUser", "CapsLock", "QuestionMaster", "PleaseAndThankYou"] as AchievementId[], + }, + { + name: "Git & Development", + description: "Version control mastery", + ids: ["CommitMaster", "PRO", "Reviewer", "IssueTracker", "GitGuru"] as AchievementId[], + }, + { + name: "Tool Mastery", + description: "Master of all tools", + ids: [ + "BashMaster", + "FileExplorer", + "SearchExpert", + "AgentCommander", + "MCPMaster", + ] as AchievementId[], + }, + { + name: "Special", + description: "Unique accomplishments", + ids: [ + "FirstMessage", + "FirstTool", + "FirstCodeBlock", + "FirstFileEdit", + "Polyglot", + "SpeedCoder", + "ClaudeConnoisseur", + ] as AchievementId[], + }, +]; diff --git a/src/lib/stores/stats.ts b/src/lib/stores/stats.ts new file mode 100644 index 0000000..c8ffc58 --- /dev/null +++ b/src/lib/stores/stats.ts @@ -0,0 +1,130 @@ +import { writable, derived } from "svelte/store"; +import { listen } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; + +export interface UsageStats { + total_input_tokens: number; + total_output_tokens: number; + total_cost_usd: number; + session_input_tokens: number; + session_output_tokens: number; + session_cost_usd: number; + model: string | null; + + // New fields + messages_exchanged: number; + session_messages_exchanged: number; + code_blocks_generated: number; + session_code_blocks_generated: number; + files_edited: number; + session_files_edited: number; + files_created: number; + session_files_created: number; + tools_usage: Record; + session_tools_usage: Record; + session_duration_seconds: number; +} + +// Main stats store +export const stats = writable({ + total_input_tokens: 0, + total_output_tokens: 0, + total_cost_usd: 0, + session_input_tokens: 0, + session_output_tokens: 0, + session_cost_usd: 0, + model: null, + messages_exchanged: 0, + session_messages_exchanged: 0, + code_blocks_generated: 0, + session_code_blocks_generated: 0, + files_edited: 0, + session_files_edited: 0, + files_created: 0, + session_files_created: 0, + tools_usage: {}, + session_tools_usage: {}, + session_duration_seconds: 0, +}); + +// Derived store for formatted display values +export const formattedStats = derived(stats, ($stats) => { + const formatNumber = (num: number) => num.toLocaleString(); + const formatCost = (cost: number) => `$${cost.toFixed(4)}`; + const formatDuration = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } + }; + + return { + totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens), + totalInputTokens: formatNumber($stats.total_input_tokens), + totalOutputTokens: formatNumber($stats.total_output_tokens), + totalCost: formatCost($stats.total_cost_usd), + sessionTokens: formatNumber($stats.session_input_tokens + $stats.session_output_tokens), + sessionInputTokens: formatNumber($stats.session_input_tokens), + sessionOutputTokens: formatNumber($stats.session_output_tokens), + sessionCost: formatCost($stats.session_cost_usd), + model: $stats.model || "No model selected", + + // New formatted fields + messagesTotal: formatNumber($stats.messages_exchanged), + messagesSession: formatNumber($stats.session_messages_exchanged), + codeBlocksTotal: formatNumber($stats.code_blocks_generated), + codeBlocksSession: formatNumber($stats.session_code_blocks_generated), + filesEditedTotal: formatNumber($stats.files_edited), + filesEditedSession: formatNumber($stats.session_files_edited), + filesCreatedTotal: formatNumber($stats.files_created), + filesCreatedSession: formatNumber($stats.session_files_created), + sessionDuration: formatDuration($stats.session_duration_seconds), + toolsUsage: $stats.tools_usage, + sessionToolsUsage: $stats.session_tools_usage, + }; +}); + +// Note: Cost calculation is now done in the Rust backend + +// Initialize stats listener +export async function initStatsListener() { + // Listen for stats updates from the backend + await listen("claude:stats", (event) => { + const payload = event.payload as { stats: UsageStats }; + const { stats: newStats } = payload; + + // The backend already tracks all totals - just set the stats directly + stats.set(newStats); + }); + + // Load initial stats from backend + try { + const initialStats = await invoke("get_usage_stats"); + stats.set(initialStats); + } catch (error) { + console.error("Failed to load initial stats:", error); + } +} + +// Reset session stats (call when starting new session) +export function resetSessionStats() { + stats.update((current) => ({ + ...current, + session_input_tokens: 0, + session_output_tokens: 0, + session_cost_usd: 0, + session_messages_exchanged: 0, + session_code_blocks_generated: 0, + session_files_edited: 0, + session_files_created: 0, + session_tools_usage: {}, + session_duration_seconds: 0, + })); +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 849574f..406ecf0 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -3,6 +3,8 @@ import { invoke } from "@tauri-apps/api/core"; import { claudeStore } from "$lib/stores/claude"; import { characterState } from "$lib/stores/character"; import { configStore } from "$lib/stores/config"; +import { initStatsListener, resetSessionStats } from "$lib/stores/stats"; +import { initAchievementsListener } from "$lib/stores/achievements"; import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import { @@ -76,6 +78,12 @@ export async function initializeTauriListeners() { // Initialize notification rules initializeNotificationRules(); + // Initialize stats listener + await initStatsListener(); + + // Initialize achievements listener + await initAchievementsListener(); + const connectionUnlisten = await listen("claude:connection", async (event) => { const status = event.payload as ConnectionStatus; claudeStore.setConnectionStatus(status); @@ -88,6 +96,7 @@ export async function initializeTauriListeners() { characterState.setState("idle"); if (!hasConnectedThisSession) { hasConnectedThisSession = true; + resetSessionStats(); // Reset session stats on new connection await sendGreeting(); } } else if (status === "disconnected") { diff --git a/src/lib/types/achievements.ts b/src/lib/types/achievements.ts new file mode 100644 index 0000000..254ae1a --- /dev/null +++ b/src/lib/types/achievements.ts @@ -0,0 +1,93 @@ +export interface AchievementUnlockedEvent { + achievement: { + id: AchievementId; + name: string; + description: string; + icon: string; + unlocked_at: string | null; + }; +} + +export type AchievementId = + // Token Milestones + | "FirstSteps" // 1,000 tokens + | "GrowingStrong" // 10,000 tokens + | "BlossomingCoder" // 100,000 tokens + | "TokenMaster" // 1,000,000 tokens + // Code Generation + | "HelloWorld" // First code block + | "CodeWizard" // 100 code blocks + | "ThousandBlocks" // 1,000 code blocks + // File Operations + | "FileManipulator" // 10 files edited + | "FileArchitect" // 100 files edited + // Conversation milestones + | "ConversationStarter" // 10 messages + | "ChattyKathy" // 100 messages + | "Conversationalist" // 1,000 messages + // Tool usage + | "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 + | "DedicatedDeveloper" // 30 days in a row + // Search and exploration + | "Explorer" // 50 searches + | "MasterSearcher" // 500 searches + // Session achievements + | "QuickSession" // Productive session < 5 min + | "FocusedWork" // 30 min session + | "DeepDive" // 2 hour session + | "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 + | "ClaudeConnoisseur" // Used all Claude models + | "MarathonCoder" // 10k tokens in one session + // Relationship & Greetings + | "GoodMorning" // Said good morning + | "GoodNight" // Said good night + | "ThankYou" // Said thank you + | "LoveYou" // Said love you + // Personality & Fun + | "EmojiUser" // Used 20+ emojis + | "CapsLock" // ALL CAPS MESSAGE + | "QuestionMaster" // Asked 50 questions + | "PleaseAndThankYou" // Polite user + // Git & Development + | "CommitMaster" // 100 commits + | "PRO" // Created 10 PRs + | "Reviewer" // Reviewed 10 PRs + | "IssueTracker" // Created 25 issues + | "GitGuru" // Used git commands + // Tool Mastery + | "BashMaster" // Used bash 100 times + | "FileExplorer" // Searched files 100 times + | "SearchExpert" // Advanced searches + | "AgentCommander" // Used task agents + | "MCPMaster"; // Used MCP tools + +export interface Achievement { + id: AchievementId; + name: string; + description: string; + icon: string; + rarity: "common" | "rare" | "epic" | "legendary"; + unlocked: boolean; + unlockedAt?: Date; + progress?: number; + maxProgress?: number; +} + +export interface AchievementCategory { + name: string; + description: string; + achievements: Achievement[]; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 6c309a8..bf80e6e 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,8 +9,11 @@ import AnimeGirl from "$lib/components/AnimeGirl.svelte"; import PermissionModal from "$lib/components/PermissionModal.svelte"; import ConfigSidebar from "$lib/components/ConfigSidebar.svelte"; + import AchievementNotification from "$lib/components/AchievementNotification.svelte"; + import AchievementsPanel from "$lib/components/AchievementsPanel.svelte"; let initialized = false; + let achievementPanelOpen = $state(false); onMount(async () => { if (!initialized) { @@ -33,7 +36,7 @@
- + (achievementPanelOpen = !achievementPanelOpen)} />
@@ -52,6 +55,11 @@ + + (achievementPanelOpen = false)} + />