generated from nhcarrigan/template
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: #45 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #45.
This commit is contained in:
@@ -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<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AchievementProgress {
|
||||
pub unlocked: HashSet<AchievementId>,
|
||||
pub newly_unlocked: Vec<AchievementId>, // Achievements unlocked but not yet notified
|
||||
#[serde(skip)]
|
||||
pub session_start: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
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<AchievementId> {
|
||||
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<AchievementId> {
|
||||
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<AchievementId> {
|
||||
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<AchievementId> = 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::<Vec<AchievementId>>(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());
|
||||
}
|
||||
}
|
||||
@@ -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<UsageStats, String> {
|
||||
let bridge = bridge.lock();
|
||||
Ok(bridge.get_stats())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_saved_achievements(app: AppHandle) -> Result<Vec<AchievementUnlockedEvent>, 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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
// 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<String, u64>,
|
||||
pub session_tools_usage: HashMap<String, u64>,
|
||||
pub session_duration_seconds: u64,
|
||||
#[serde(skip)]
|
||||
pub session_start: Option<Instant>,
|
||||
|
||||
// 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<crate::achievements::AchievementId> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<u32>,
|
||||
#[serde(default)]
|
||||
permission_denials: Option<Vec<PermissionDenial>>,
|
||||
#[serde(default)]
|
||||
usage: Option<UsageInfo>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,6 +105,8 @@ pub struct AssistantMessageContent {
|
||||
pub model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub usage: Option<UsageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+195
-6
@@ -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<String>,
|
||||
mcp_config_file: Option<NamedTempFile>,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
}
|
||||
|
||||
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<RwLock<UsageStats>>) {
|
||||
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<RwLock<UsageStats>>) -> 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::<Vec<String>>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user