feat: stats and achievements (#45)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 53s
CI / Lint & Test (push) Successful in 14m11s
CI / Build Linux (push) Successful in 16m47s
CI / Build Windows (cross-compile) (push) Successful in 26m56s

### 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:
2026-01-19 20:51:53 -08:00
committed by Naomi Carrigan
parent a8f98406e1
commit 70fcaa8650
24 changed files with 2995 additions and 19 deletions
+3
View File
@@ -442,8 +442,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys",
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen",
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
@@ -1411,6 +1413,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
name = "hikari-desktop" name = "hikari-desktop"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"parking_lot", "parking_lot",
"serde", "serde",
"serde_json", "serde_json",
+1
View File
@@ -26,6 +26,7 @@ tauri-plugin-store = "2.4.2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"
tauri-plugin-os = "2" tauri-plugin-os = "2"
tempfile = "3" tempfile = "3"
chrono = { version = "0.4.43", features = ["serde"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [ windows = { version = "0.62", features = [
+808
View File
@@ -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(&current_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());
}
}
+28
View File
@@ -2,7 +2,9 @@ use tauri::{AppHandle, State};
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::config::{ClaudeStartOptions, HikariConfig};
use crate::stats::UsageStats;
use crate::wsl_bridge::SharedBridge; use crate::wsl_bridge::SharedBridge;
use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent};
const CONFIG_STORE_KEY: &str = "config"; const CONFIG_STORE_KEY: &str = "config";
@@ -72,3 +74,29 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str
Ok(()) 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)
}
+5
View File
@@ -1,6 +1,8 @@
mod achievements;
mod commands; mod commands;
mod config; mod config;
mod notifications; mod notifications;
mod stats;
mod types; mod types;
mod wsl_bridge; mod wsl_bridge;
mod wsl_notifications; mod wsl_notifications;
@@ -10,6 +12,7 @@ mod windows_toast;
use commands::*; use commands::*;
use notifications::*; use notifications::*;
use wsl_bridge::create_shared_bridge; use wsl_bridge::create_shared_bridge;
use commands::load_saved_achievements;
use wsl_notifications::*; use wsl_notifications::*;
use vbs_notification::*; use vbs_notification::*;
use windows_toast::*; use windows_toast::*;
@@ -35,6 +38,8 @@ pub fn run() {
select_wsl_directory, select_wsl_directory,
get_config, get_config,
save_config, save_config,
get_usage_stats,
load_saved_achievements,
send_windows_notification, send_windows_notification,
send_simple_notification, send_simple_notification,
send_windows_toast, send_windows_toast,
+216
View File
@@ -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);
}
}
+10
View File
@@ -1,5 +1,11 @@
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum CharacterState { pub enum CharacterState {
@@ -87,6 +93,8 @@ pub enum ClaudeMessage {
num_turns: Option<u32>, num_turns: Option<u32>,
#[serde(default)] #[serde(default)]
permission_denials: Option<Vec<PermissionDenial>>, permission_denials: Option<Vec<PermissionDenial>>,
#[serde(default)]
usage: Option<UsageInfo>,
}, },
} }
@@ -97,6 +105,8 @@ pub struct AssistantMessageContent {
pub model: Option<String>, pub model: Option<String>,
#[serde(default)] #[serde(default)]
pub stop_reason: Option<String>, pub stop_reason: Option<String>,
#[serde(default)]
pub usage: Option<UsageInfo>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
+195 -6
View File
@@ -10,7 +10,10 @@ use tempfile::NamedTempFile;
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use crate::config::ClaudeStartOptions; 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::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 SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"]; const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
@@ -72,6 +75,7 @@ pub struct WslBridge {
working_directory: String, working_directory: String,
session_id: Option<String>, session_id: Option<String>,
mcp_config_file: Option<NamedTempFile>, mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>,
} }
impl WslBridge { impl WslBridge {
@@ -82,14 +86,37 @@ impl WslBridge {
working_directory: String::new(), working_directory: String::new(),
session_id: None, session_id: None,
mcp_config_file: 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> { pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
if self.process.is_some() { if self.process.is_some() {
return Err("Process already running".to_string()); 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; let working_dir = &options.working_dir;
self.working_directory = working_dir.clone(); self.working_directory = working_dir.clone();
@@ -249,10 +276,22 @@ impl WslBridge {
self.stdin = stdin; self.stdin = stdin;
self.process = Some(child); 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 { if let Some(stdout) = stdout {
let app_clone = app.clone(); let app_clone = app.clone();
let stats_clone = self.stats.clone();
thread::spawn(move || { 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 { pub fn get_working_directory(&self) -> &str {
&self.working_directory &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 { 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); let reader = BufReader::new(stdout);
for line in reader.lines() { for line in reader.lines() {
match line { match line {
Ok(line) if !line.is_empty() => { 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); 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) let message: ClaudeMessage = serde_json::from_str(line)
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, 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 state = CharacterState::Typing;
let mut tool_name = None; 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 { for block in &message.content {
match block { match block {
ContentBlock::ToolUse { name, input, .. } => { ContentBlock::ToolUse { name, input, .. } => {
tool_name = Some(name.clone()); tool_name = Some(name.clone());
state = get_tool_state(name); 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 desc = format_tool_description(name, input);
let _ = app.emit("claude:output", OutputEvent { let _ = app.emit("claude:output", OutputEvent {
line_type: "tool".to_string(), line_type: "tool".to_string(),
@@ -393,6 +481,12 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
}); });
} }
ContentBlock::Text { text } => { 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 { let _ = app.emit("claude:output", OutputEvent {
line_type: "assistant".to_string(), line_type: "assistant".to_string(),
content: text.clone(), 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" { let state = if subtype == "success" {
CharacterState::Success CharacterState::Success
} else { } else {
CharacterState::Error 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 // Only emit error results - success content is already sent via Assistant message
if subtype != "success" { if subtype != "success" {
if let Some(text) = result { 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); 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); emit_state_change(app, CharacterState::Thinking, None);
} }
} }
+14
View File
@@ -10,7 +10,14 @@
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: #a0a0a0; --text-secondary: #a0a0a0;
--text-tertiary: #6b7280;
--border-color: #2a2a4a; --border-color: #2a2a4a;
/* Terminal specific colors */
--terminal-user: #22d3ee;
--terminal-tool: #c084fc;
--terminal-tool-name: #ddd6fe;
--terminal-error: #f87171;
} }
[data-theme="light"] { [data-theme="light"] {
@@ -22,7 +29,14 @@
--accent-secondary: #ff6b9d; --accent-secondary: #ff6b9d;
--text-primary: #1a1a2e; --text-primary: #1a1a2e;
--text-secondary: #5a5a7a; --text-secondary: #5a5a7a;
--text-tertiary: #9ca3af;
--border-color: #d0d0e0; --border-color: #d0d0e0;
/* Terminal specific colors */
--terminal-user: #0891b2;
--terminal-tool: #7c3aed;
--terminal-tool-name: #8b5cf6;
--terminal-error: #dc2626;
} }
html, html,
@@ -0,0 +1,202 @@
<script lang="ts">
import { onMount } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import { listen } from "@tauri-apps/api/event";
import type { AchievementUnlockedEvent } from "$lib/types/achievements";
let achievements = $state<AchievementUnlockedEvent[]>([]);
let currentAchievement = $state<AchievementUnlockedEvent | null>(null);
let showNotification = $state(false);
onMount(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
achievements.push(event.payload);
if (!showNotification) {
showNext();
}
});
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
});
function showNext() {
if (achievements.length > 0) {
currentAchievement = achievements.shift() || null;
showNotification = true;
// Auto-hide after 5 seconds
setTimeout(() => {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}, 5000);
}
}
function dismiss() {
showNotification = false;
// Show next achievement after animation completes
setTimeout(() => showNext(), 300);
}
function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "from-yellow-400 to-orange-500";
case "epic":
return "from-purple-400 to-pink-500";
case "rare":
return "from-blue-400 to-indigo-500";
default:
return "from-green-400 to-emerald-500";
}
}
function getAchievementRarity(id: string): string {
// Determine rarity based on achievement ID
if (id === "TokenMaster") return "legendary";
if (["CodeMachine", "Unstoppable"].includes(id)) return "epic";
if (
[
"BlossomingCoder",
"CodeWizard",
"MasterBuilder",
"EnduranceChamp",
"DeepDive",
"CreativeCoder",
].includes(id)
)
return "rare";
return "common";
}
</script>
{#if showNotification && currentAchievement}
<div
class="fixed top-20 right-4 z-50 max-w-sm"
in:fly={{ x: 300, duration: 500, easing: cubicOut }}
out:fade={{ duration: 300 }}
>
<!-- Backdrop with animated gradient border -->
<div class="relative p-[2px] rounded-lg overflow-hidden">
<!-- Animated gradient border -->
<div
class="absolute inset-0 bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} animate-pulse"
></div>
<!-- Main notification content -->
<div class="relative bg-[var(--bg-primary)] rounded-lg p-4 shadow-2xl backdrop-blur-sm">
<button
onclick={dismiss}
onkeydown={(e) => e.key === "Enter" && dismiss()}
class="absolute top-2 right-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
aria-label="Dismiss notification"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
<div class="flex items-start gap-4">
<!-- Icon with animated sparkles -->
<div class="relative flex-shrink-0">
<div class="text-5xl animate-bounce">{currentAchievement.achievement.icon}</div>
<!-- Sparkle animations -->
<div class="absolute -top-1 -right-1 text-yellow-400 animate-ping"></div>
<div
class="absolute -bottom-1 -left-1 text-yellow-400 animate-ping animation-delay-200"
>
</div>
<div class="absolute top-1/2 -right-2 text-yellow-400 animate-ping animation-delay-400">
</div>
</div>
<!-- Text content -->
<div class="flex-1 min-w-0 pt-1">
<h3
class="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide"
>
Achievement Unlocked!
</h3>
<p class="text-lg font-bold text-[var(--text-primary)] mt-1">
{currentAchievement.achievement.name}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{currentAchievement.achievement.description}
</p>
<!-- Rarity badge -->
<div class="mt-2 inline-flex items-center">
<span
class="px-2 py-1 text-xs font-medium rounded-full bg-gradient-to-r {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} text-white capitalize"
>
{getAchievementRarity(currentAchievement.achievement.id)}
</span>
</div>
</div>
</div>
<!-- Celebration confetti effect (CSS only) -->
<div class="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
{#each Array(10) as _ (_)}
<div
class="absolute w-2 h-2 bg-gradient-to-br {getRarityColor(
getAchievementRarity(currentAchievement.achievement.id)
)} rounded-full animate-fall"
style="left: {Math.random() * 100}%; animation-delay: {Math.random() *
2}s; animation-duration: {2 + Math.random() * 2}s;"
></div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<style>
@keyframes fall {
0% {
transform: translateY(-20px) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(400px) rotate(720deg);
opacity: 0;
}
}
.animate-fall {
animation: fall linear infinite;
}
.animation-delay-200 {
animation-delay: 200ms;
}
.animation-delay-400 {
animation-delay: 400ms;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { slide } from "svelte/transition";
import { quintOut } from "svelte/easing";
import {
achievementsStore,
achievementProgress,
achievementCategories,
} from "$lib/stores/achievements";
import type { Achievement } from "$lib/types/achievements";
interface Props {
isOpen: boolean;
onClose?: () => void;
}
const { isOpen = $bindable(false), onClose }: Props = $props();
let selectedCategory = $state<string | null>(null);
const achievementsState = $derived($achievementsStore);
const progress = $derived($achievementProgress);
function getRarityColor(rarity: string): string {
switch (rarity) {
case "legendary":
return "text-yellow-500 dark:text-yellow-400";
case "epic":
return "text-purple-500 dark:text-purple-400";
case "rare":
return "text-blue-500 dark:text-blue-400";
default:
return "text-green-500 dark:text-green-400";
}
}
function getRarityBg(rarity: string): string {
switch (rarity) {
case "legendary":
return "bg-yellow-500/10";
case "epic":
return "bg-purple-500/10";
case "rare":
return "bg-blue-500/10";
default:
return "bg-green-500/10";
}
}
function formatDate(date: Date | undefined): string {
if (!date) return "";
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function getAchievementsForCategory(categoryIds: string[]): Achievement[] {
return categoryIds
.map(
(id) => achievementsState.achievements[id as keyof typeof achievementsState.achievements]
)
.filter(Boolean);
}
</script>
<!-- Achievements panel -->
{#if isOpen}
<div
class="fixed inset-0 bg-black/50 z-40"
onclick={onClose}
onkeydown={(e) => e.key === "Escape" && onClose?.()}
role="button"
tabindex="-1"
aria-label="Close achievements panel"
transition:slide={{ duration: 300, easing: quintOut }}
></div>
<div
class="fixed left-0 top-0 h-full w-96 bg-[var(--bg-primary)] border-r border-[var(--border-color)]
shadow-2xl z-50 flex flex-col"
transition:slide={{ duration: 300, easing: quintOut, axis: "x" }}
>
<!-- Header -->
<div class="p-6 border-b border-[var(--border-color)]">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-[var(--text-primary)]">Achievements</h2>
<button
onclick={onClose}
onkeydown={(e) => e.key === "Enter" && onClose?.()}
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label="Close achievements panel"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</button>
</div>
<!-- Overall progress -->
<div class="mt-4">
<div
class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400 mb-2"
>
<span>{progress.unlocked} / {progress.total} Unlocked</span>
<span>{progress.percentage}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-[var(--accent-primary)] to-[var(--accent-secondary)] h-2 rounded-full transition-all duration-500"
style="width: {progress.percentage}%"
></div>
</div>
</div>
</div>
<!-- Categories -->
<div class="flex-1 overflow-y-auto">
{#each achievementCategories as category (category.name)}
{@const achievements = getAchievementsForCategory(category.ids)}
{@const unlockedCount = achievements.filter((a) => a.unlocked).length}
<div class="border-b border-[var(--border-color)]">
<button
onclick={() =>
(selectedCategory = selectedCategory === category.name ? null : category.name)}
onkeydown={(e) =>
e.key === "Enter" &&
(selectedCategory = selectedCategory === category.name ? null : category.name)}
class="w-full p-4 text-left hover:bg-[var(--bg-secondary)] transition-colors"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold text-[var(--text-primary)]">{category.name}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">{category.description}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{unlockedCount} / {achievements.length}
</span>
<svg
class="w-5 h-5 transition-transform {selectedCategory === category.name
? 'rotate-180'
: ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</div>
</div>
</button>
{#if selectedCategory === category.name}
<div class="p-4 space-y-3" transition:slide={{ duration: 200, easing: quintOut }}>
{#each achievements as achievement (achievement.id)}
<div
class="p-3 rounded-lg border {achievement.unlocked
? 'border-[var(--border-color)] bg-[var(--bg-secondary)]'
: 'border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-800 opacity-50'}"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="text-3xl flex-shrink-0 {achievement.unlocked ? '' : 'grayscale'}">
{achievement.icon}
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-[var(--text-primary)]">
{achievement.name}
</h4>
<span
class="text-xs px-2 py-0.5 rounded-full {getRarityBg(
achievement.rarity
)} {getRarityColor(achievement.rarity)} capitalize"
>
{achievement.rarity}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{achievement.description}
</p>
{#if achievement.unlocked && achievement.unlockedAt}
<p class="text-xs text-gray-500 dark:text-gray-500 mt-2">
Unlocked {formatDate(achievement.unlockedAt)}
</p>
{:else if achievement.maxProgress && achievement.progress !== undefined}
<!-- Progress bar for locked achievements -->
<div class="mt-2">
<div class="flex items-center justify-between text-xs text-gray-500 mb-1">
<span>Progress</span>
<span>{achievement.progress} / {achievement.maxProgress}</span>
</div>
<div class="w-full bg-gray-300 dark:bg-gray-700 rounded-full h-1.5">
<div
class="bg-gray-500 h-1.5 rounded-full transition-all duration-300"
style="width: {Math.min(
(achievement.progress / achievement.maxProgress) * 100,
100
)}%"
></div>
</div>
</div>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
<!-- Footer with last unlocked -->
{#if achievementsState.lastUnlocked}
<div class="p-4 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Unlocked:</p>
<div class="flex items-center gap-2">
<span class="text-xl">{achievementsState.lastUnlocked.icon}</span>
<div>
<p class="font-semibold text-[var(--text-primary)]">
{achievementsState.lastUnlocked.name}
</p>
<p class="text-xs text-gray-500">
{formatDate(achievementsState.lastUnlocked.unlockedAt)}
</p>
</div>
</div>
</div>
{/if}
</div>
{/if}
<style>
/* Custom scrollbar for achievement list */
:global(.overflow-y-auto::-webkit-scrollbar) {
width: 8px;
}
:global(.overflow-y-auto::-webkit-scrollbar-track) {
background: var(--bg-secondary);
}
:global(.overflow-y-auto::-webkit-scrollbar-thumb) {
background: var(--border-color);
border-radius: 4px;
}
:global(.overflow-y-auto::-webkit-scrollbar-thumb:hover) {
background: var(--accent-primary);
}
</style>
+1 -1
View File
@@ -54,7 +54,7 @@
disabled={!isConnected || isSubmitting} disabled={!isConnected || isSubmitting}
rows={1} rows={1}
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] 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)] focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)]
disabled:opacity-50 disabled:cursor-not-allowed disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200" transition-all duration-200"
+168
View File
@@ -0,0 +1,168 @@
<script lang="ts">
import { formattedStats } from "$lib/stores/stats";
import { fade } from "svelte/transition";
let showToolsBreakdown = false;
</script>
<div class="stats-display" transition:fade={{ duration: 200 }}>
<div class="stats-row">
<span class="stat-label">Duration:</span>
<span class="stat-value">{$formattedStats.sessionDuration}</span>
</div>
<div class="stats-row">
<span class="stat-label">Messages:</span>
<span class="stat-value">{$formattedStats.messagesSession}</span>
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
</div>
<div class="stats-section">
<h3>Tokens & Cost</h3>
<div class="stat-row">
<span class="stat-label">Session:</span>
<span class="stat-value">{$formattedStats.sessionTokens}</span>
<span class="stat-cost">{$formattedStats.sessionCost}</span>
</div>
<div class="stat-row stat-detail">
<span class="stat-label">Input:</span>
<span class="stat-value">{$formattedStats.sessionInputTokens}</span>
</div>
<div class="stat-row stat-detail">
<span class="stat-label">Output:</span>
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
</div>
<div class="stat-row stat-highlight">
<span class="stat-label">Total:</span>
<span class="stat-value">{$formattedStats.totalTokens}</span>
<span class="stat-cost">{$formattedStats.totalCost}</span>
</div>
</div>
<div class="stats-section">
<h3>Activity</h3>
<div class="stat-row">
<span class="stat-label">Code blocks:</span>
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files edited:</span>
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
</div>
<div class="stat-row">
<span class="stat-label">Files created:</span>
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
</div>
</div>
{#if Object.keys($formattedStats.sessionToolsUsage).length > 0}
<div class="stats-section">
<h3 class="tools-header">
<button class="tools-toggle" onclick={() => (showToolsBreakdown = !showToolsBreakdown)}>
Tools Used
<span class="toggle-icon">{showToolsBreakdown ? "▼" : "▶"}</span>
</button>
</h3>
{#if showToolsBreakdown}
<div class="tools-breakdown">
{#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count] (tool)}
<div class="stat-row stat-detail">
<span class="stat-label">{tool}:</span>
<span class="stat-value">{count}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
<div class="model-info">
<span class="model-label">Model:</span>
<span class="model-value">{$formattedStats.model}</span>
</div>
</div>
<style>
.stats-display {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 0.85rem;
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.1),
0 1px 3px rgba(0, 0, 0, 0.08);
}
.stats-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stats-section h3 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border-color);
}
.stat-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.125rem 0;
}
.stat-detail {
margin-left: 1rem;
font-size: 0.8rem;
opacity: 0.8;
}
.stat-highlight {
font-weight: 600;
color: var(--accent-primary);
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px solid var(--border-color);
}
.stat-label {
color: var(--text-secondary, #9ca3af);
}
.stat-value {
font-family: var(--font-mono, monospace);
color: var(--text-primary, #e5e7eb);
}
.model-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background: var(--bg-primary);
border-radius: 4px;
font-size: 0.8rem;
}
.model-label {
color: var(--text-secondary, #9ca3af);
font-weight: 600;
}
.model-value {
font-family: var(--font-mono, monospace);
color: var(--text-primary, #e5e7eb);
font-size: 0.75rem;
}
</style>
+59
View File
@@ -1,4 +1,10 @@
<script lang="ts"> <script lang="ts">
interface Props {
onToggleAchievements?: () => void;
}
const { onToggleAchievements = () => {} }: Props = $props();
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app"; import { getVersion } from "@tauri-apps/api/app";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
@@ -7,6 +13,8 @@
import { configStore, type HikariConfig } from "$lib/stores/config"; import { configStore, type HikariConfig } from "$lib/stores/config";
import type { ConnectionStatus } from "$lib/types/messages"; import type { ConnectionStatus } from "$lib/types/messages";
import { onMount } from "svelte"; import { onMount } from "svelte";
import StatsDisplay from "./StatsDisplay.svelte";
import { achievementProgress } from "$lib/stores/achievements";
const DISCORD_URL = "https://chat.nhcarrigan.com"; const DISCORD_URL = "https://chat.nhcarrigan.com";
@@ -16,6 +24,8 @@
let isConnecting = $state(false); let isConnecting = $state(false);
let grantedToolsList: string[] = $state([]); let grantedToolsList: string[] = $state([]);
let appVersion = $state(""); let appVersion = $state("");
let showStats = $state(false);
const progress = $derived($achievementProgress);
let currentConfig: HikariConfig = $state({ let currentConfig: HikariConfig = $state({
model: null, model: null,
api_key: null, api_key: null,
@@ -126,6 +136,10 @@
return "Disconnected"; return "Disconnected";
} }
} }
function toggleAchievements() {
onToggleAchievements();
}
</script> </script>
<div <div
@@ -167,6 +181,36 @@
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button
onclick={toggleAchievements}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors relative"
title="Achievements"
>
<span class="text-lg">🏆</span>
{#if progress.unlocked > 0}
<span
class="absolute -top-1 -right-1 bg-[var(--accent-primary)] text-white text-xs rounded-full w-4 h-4 flex items-center justify-center text-[10px]"
>
{progress.unlocked}
</span>
{/if}
</button>
<button
onclick={() => (showStats = !showStats)}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
? 'text-[var(--accent-primary)]'
: ''}"
title="Usage Stats"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zM13 19v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2h2a2 2 0 002-2zM21 19V8a2 2 0 00-2-2h-2a2 2 0 00-2 2v11a2 2 0 002 2h2a2 2 0 002-2z"
/>
</svg>
</button>
<button <button
onclick={configStore.openSidebar} onclick={configStore.openSidebar}
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors" class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
@@ -201,6 +245,12 @@
{#if appVersion} {#if appVersion}
<span class="text-xs text-gray-600">v{appVersion}</span> <span class="text-xs text-gray-600">v{appVersion}</span>
{/if} {/if}
{#if showStats}
<div class="absolute top-full right-0 mt-2 mr-4 z-50">
<StatsDisplay />
</div>
{/if}
{#if connectionStatus === "connected"} {#if connectionStatus === "connected"}
<button <button
onclick={handleDisconnect} onclick={handleDisconnect}
@@ -219,3 +269,12 @@
{/if} {/if}
</div> </div>
</div> </div>
{#if showStats}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-40" onclick={() => (showStats = false)}></div>
<div class="fixed top-14 right-4 z-50">
<StatsDisplay />
</div>
{/if}
+58 -11
View File
@@ -25,17 +25,17 @@
function getLineClass(type: string): string { function getLineClass(type: string): string {
switch (type) { switch (type) {
case "user": case "user":
return "text-cyan-400"; return "terminal-user";
case "assistant": case "assistant":
return "text-gray-100"; return "terminal-assistant";
case "system": case "system":
return "text-gray-500 italic"; return "terminal-system italic";
case "tool": case "tool":
return "text-purple-400"; return "terminal-tool";
case "error": case "error":
return "text-red-400"; return "terminal-error";
default: default:
return "text-gray-300"; return "terminal-default";
} }
} }
@@ -75,7 +75,7 @@
<div class="w-3 h-3 rounded-full bg-yellow-500"></div> <div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div> <div class="w-3 h-3 rounded-full bg-green-500"></div>
</div> </div>
<span class="text-sm text-gray-400 ml-2">Terminal</span> <span class="text-sm terminal-header-text ml-2">Terminal</span>
</div> </div>
<div <div
@@ -84,16 +84,18 @@
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm" class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
> >
{#if lines.length === 0} {#if lines.length === 0}
<div class="text-gray-500 italic">Waiting for Claude... Type a message below to start!</div> <div class="terminal-waiting italic">
Waiting for Claude... Type a message below to start!
</div>
{:else} {:else}
{#each lines as line (line.id)} {#each lines as line (line.id)}
<div class="terminal-line mb-2 {getLineClass(line.type)}"> <div class="terminal-line mb-2 {getLineClass(line.type)}">
<span class="text-gray-600 text-xs mr-2">{formatTime(line.timestamp)}</span> <span class="terminal-timestamp text-xs mr-2">{formatTime(line.timestamp)}</span>
{#if getLinePrefix(line.type)} {#if getLinePrefix(line.type)}
<span class="text-gray-500 mr-2">{getLinePrefix(line.type)}</span> <span class="terminal-prefix mr-2">{getLinePrefix(line.type)}</span>
{/if} {/if}
{#if line.toolName} {#if line.toolName}
<span class="text-purple-300 mr-2">[{line.toolName}]</span> <span class="terminal-tool-name mr-2">[{line.toolName}]</span>
{/if} {/if}
<span class="whitespace-pre-wrap">{line.content}</span> <span class="whitespace-pre-wrap">{line.content}</span>
</div> </div>
@@ -107,4 +109,49 @@
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-color) var(--bg-terminal); 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);
}
</style> </style>
+7
View File
@@ -4,6 +4,7 @@ export enum NotificationType {
PERMISSION = "permission", PERMISSION = "permission",
CONNECTION = "connection", CONNECTION = "connection",
TASK_START = "task_start", TASK_START = "task_start",
ACHIEVEMENT = "achievement",
} }
export interface NotificationSound { export interface NotificationSound {
@@ -45,4 +46,10 @@ export const NOTIFICATION_SOUNDS: Record<NotificationType, NotificationSound> =
phrase: "Working on it!", phrase: "Working on it!",
volume: 0.6, volume: 0.6,
}, },
[NotificationType.ACHIEVEMENT]: {
type: NotificationType.ACHIEVEMENT,
filename: "achievement.mp3",
phrase: "Achievement Get~!",
volume: 0.8,
},
}; };
+19
View File
@@ -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);
}
}
+649
View File
@@ -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<AchievementId, Achievement>;
totalUnlocked: number;
lastUnlocked: Achievement | null;
}
// Initial achievement definitions
const achievementDefinitions: Record<
AchievementId,
Omit<Achievement, "unlocked" | "unlockedAt">
> = {
// 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<AchievementId, Achievement> = {} 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<AchievementState>({
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<string, Achievement[]> = {
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<AchievementUnlockedEvent>("achievement:unlocked", (event) => {
achievementsStore.unlockAchievement(event.payload);
});
// Load saved achievements from persistent storage
try {
const savedAchievements = await invoke<AchievementUnlockedEvent[]>("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[],
},
];
+130
View File
@@ -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<string, number>;
session_tools_usage: Record<string, number>;
session_duration_seconds: number;
}
// Main stats store
export const stats = writable<UsageStats>({
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<UsageStats>("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,
}));
}
+9
View File
@@ -3,6 +3,8 @@ import { invoke } from "@tauri-apps/api/core";
import { claudeStore } from "$lib/stores/claude"; import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character"; import { characterState } from "$lib/stores/character";
import { configStore } from "$lib/stores/config"; 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 { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states"; import type { CharacterState } from "$lib/types/states";
import { import {
@@ -76,6 +78,12 @@ export async function initializeTauriListeners() {
// Initialize notification rules // Initialize notification rules
initializeNotificationRules(); initializeNotificationRules();
// Initialize stats listener
await initStatsListener();
// Initialize achievements listener
await initAchievementsListener();
const connectionUnlisten = await listen<string>("claude:connection", async (event) => { const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
const status = event.payload as ConnectionStatus; const status = event.payload as ConnectionStatus;
claudeStore.setConnectionStatus(status); claudeStore.setConnectionStatus(status);
@@ -88,6 +96,7 @@ export async function initializeTauriListeners() {
characterState.setState("idle"); characterState.setState("idle");
if (!hasConnectedThisSession) { if (!hasConnectedThisSession) {
hasConnectedThisSession = true; hasConnectedThisSession = true;
resetSessionStats(); // Reset session stats on new connection
await sendGreeting(); await sendGreeting();
} }
} else if (status === "disconnected") { } else if (status === "disconnected") {
+93
View File
@@ -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[];
}
+9 -1
View File
@@ -9,8 +9,11 @@
import AnimeGirl from "$lib/components/AnimeGirl.svelte"; import AnimeGirl from "$lib/components/AnimeGirl.svelte";
import PermissionModal from "$lib/components/PermissionModal.svelte"; import PermissionModal from "$lib/components/PermissionModal.svelte";
import ConfigSidebar from "$lib/components/ConfigSidebar.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 initialized = false;
let achievementPanelOpen = $state(false);
onMount(async () => { onMount(async () => {
if (!initialized) { if (!initialized) {
@@ -33,7 +36,7 @@
</script> </script>
<div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden"> <div class="app-container h-screen w-screen flex flex-col bg-[var(--bg-primary)] overflow-hidden">
<StatusBar /> <StatusBar onToggleAchievements={() => (achievementPanelOpen = !achievementPanelOpen)} />
<main class="flex-1 flex overflow-hidden"> <main class="flex-1 flex overflow-hidden">
<!-- Left panel: Character display --> <!-- Left panel: Character display -->
@@ -52,6 +55,11 @@
<PermissionModal /> <PermissionModal />
<ConfigSidebar /> <ConfigSidebar />
<AchievementNotification />
<AchievementsPanel
bind:isOpen={achievementPanelOpen}
onClose={() => (achievementPanelOpen = false)}
/>
</div> </div>
<style> <style>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import { testAchievementSound } from "$lib/sounds/achievement";
import { invoke } from "@tauri-apps/api/core";
async function testSound() {
testAchievementSound();
}
async function triggerAchievement() {
// This will trigger an achievement that hasn't been unlocked yet
try {
await invoke("check_achievements", {
eventType: "message_sent",
data: {},
});
} catch (error) {
console.error("Failed to trigger achievement:", error);
}
}
</script>
<div class="container mx-auto p-8">
<h1 class="text-2xl font-bold mb-6">Achievement Sound Test</h1>
<div class="space-y-4">
<button
onclick={testSound}
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Test Achievement Sound
</button>
<button
onclick={triggerAchievement}
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
>
Trigger Test Achievement
</button>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">
Click the first button to test just the sound effect.<br />
Click the second button to trigger a real achievement (if any are available to unlock).
</p>
</div>
</div>
Binary file not shown.