generated from nhcarrigan/template
feat: stats and achievements (#45)
### Explanation _No response_ ### Issue Closes #39 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #45 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #45.
This commit is contained in:
+195
-6
@@ -10,7 +10,10 @@ use tempfile::NamedTempFile;
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::stats::{UsageStats, StatsUpdateEvent};
|
||||
use parking_lot::RwLock;
|
||||
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent};
|
||||
use crate::achievements::{get_achievement_info, AchievementUnlockedEvent};
|
||||
|
||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||
const CODING_TOOLS: [&str; 3] = ["Edit", "Write", "NotebookEdit"];
|
||||
@@ -72,6 +75,7 @@ pub struct WslBridge {
|
||||
working_directory: String,
|
||||
session_id: Option<String>,
|
||||
mcp_config_file: Option<NamedTempFile>,
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
}
|
||||
|
||||
impl WslBridge {
|
||||
@@ -82,14 +86,37 @@ impl WslBridge {
|
||||
working_directory: String::new(),
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn new_with_loaded_achievements(app: &tauri::AppHandle) -> Self {
|
||||
let bridge = Self::new();
|
||||
|
||||
// Load saved achievements into the stats
|
||||
let achievements = crate::achievements::load_achievements(app).await;
|
||||
println!("Loaded achievements into bridge: {} unlocked", achievements.unlocked.len());
|
||||
bridge.stats.write().achievements = achievements;
|
||||
|
||||
bridge
|
||||
}
|
||||
|
||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||
if self.process.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
|
||||
// Load saved achievements when starting a new session
|
||||
let app_clone = app.clone();
|
||||
let stats = self.stats.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Loading saved achievements...");
|
||||
let achievements = crate::achievements::load_achievements(&app_clone).await;
|
||||
println!("Loaded {} unlocked achievements", achievements.unlocked.len());
|
||||
stats.write().achievements = achievements;
|
||||
});
|
||||
|
||||
let working_dir = &options.working_dir;
|
||||
self.working_directory = working_dir.clone();
|
||||
|
||||
@@ -249,10 +276,22 @@ impl WslBridge {
|
||||
self.stdin = stdin;
|
||||
self.process = Some(child);
|
||||
|
||||
// Reset session stats when starting new session
|
||||
self.stats.write().reset_session();
|
||||
|
||||
// Load saved achievements
|
||||
let app_handle = app.clone();
|
||||
let stats_clone = self.stats.clone();
|
||||
tokio::spawn(async move {
|
||||
let saved_progress = crate::achievements::load_achievements(&app_handle).await;
|
||||
stats_clone.write().achievements = saved_progress;
|
||||
});
|
||||
|
||||
if let Some(stdout) = stdout {
|
||||
let app_clone = app.clone();
|
||||
let stats_clone = self.stats.clone();
|
||||
thread::spawn(move || {
|
||||
handle_stdout(stdout, app_clone);
|
||||
handle_stdout(stdout, app_clone, stats_clone);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,6 +350,20 @@ impl WslBridge {
|
||||
pub fn get_working_directory(&self) -> &str {
|
||||
&self.working_directory
|
||||
}
|
||||
|
||||
pub fn get_stats(&self) -> UsageStats {
|
||||
self.stats.read().clone()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn update_stats(&mut self, input_tokens: u64, output_tokens: u64, model: &str) {
|
||||
self.stats.write().add_usage(input_tokens, output_tokens, model);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn reset_session_stats(&mut self) {
|
||||
self.stats.write().reset_session();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WslBridge {
|
||||
@@ -319,13 +372,13 @@ impl Default for WslBridge {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) {
|
||||
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>) {
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
for line in reader.lines() {
|
||||
match line {
|
||||
Ok(line) if !line.is_empty() => {
|
||||
if let Err(e) = process_json_line(&line, &app) {
|
||||
if let Err(e) = process_json_line(&line, &app, &stats) {
|
||||
eprintln!("Error processing line: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -358,7 +411,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>) -> Result<(), String> {
|
||||
let message: ClaudeMessage = serde_json::from_str(line)
|
||||
.map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?;
|
||||
|
||||
@@ -379,12 +432,47 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
let mut state = CharacterState::Typing;
|
||||
let mut tool_name = None;
|
||||
|
||||
// Only update stats if we have usage information
|
||||
if let Some(usage) = &message.usage {
|
||||
if let Some(model) = &message.model {
|
||||
// Batch all stats updates in a single write lock
|
||||
{
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.increment_messages();
|
||||
stats_guard.add_usage(usage.input_tokens, usage.output_tokens, model);
|
||||
stats_guard.get_session_duration();
|
||||
}
|
||||
|
||||
// Don't emit here - we'll emit on Result message instead
|
||||
// This reduces the frequency of updates
|
||||
} else {
|
||||
// Just increment message count if no usage info
|
||||
stats.write().increment_messages();
|
||||
}
|
||||
} else {
|
||||
// Just increment message count if no usage info
|
||||
stats.write().increment_messages();
|
||||
}
|
||||
|
||||
for block in &message.content {
|
||||
match block {
|
||||
ContentBlock::ToolUse { name, input, .. } => {
|
||||
tool_name = Some(name.clone());
|
||||
state = get_tool_state(name);
|
||||
|
||||
// Batch tool tracking updates
|
||||
{
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.increment_tool_usage(name);
|
||||
|
||||
// Track file operations
|
||||
match name.as_str() {
|
||||
"Edit" => stats_guard.increment_files_edited(),
|
||||
"Write" => stats_guard.increment_files_created(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let desc = format_tool_description(name, input);
|
||||
let _ = app.emit("claude:output", OutputEvent {
|
||||
line_type: "tool".to_string(),
|
||||
@@ -393,6 +481,12 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
});
|
||||
}
|
||||
ContentBlock::Text { text } => {
|
||||
// Count code blocks in the text
|
||||
let code_blocks = text.matches("```").count() / 2;
|
||||
for _ in 0..code_blocks {
|
||||
stats.write().increment_code_blocks();
|
||||
}
|
||||
|
||||
let _ = app.emit("claude:output", OutputEvent {
|
||||
line_type: "assistant".to_string(),
|
||||
content: text.clone(),
|
||||
@@ -440,13 +534,55 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
ClaudeMessage::Result { subtype, result, permission_denials, .. } => {
|
||||
ClaudeMessage::Result { subtype, result, permission_denials, usage: _, .. } => {
|
||||
let state = if subtype == "success" {
|
||||
CharacterState::Success
|
||||
} else {
|
||||
CharacterState::Error
|
||||
};
|
||||
|
||||
// Always emit updated stats on result message (less frequent)
|
||||
// This includes the latest session duration
|
||||
let newly_unlocked = {
|
||||
let mut stats_guard = stats.write();
|
||||
stats_guard.get_session_duration();
|
||||
println!("Checking achievements after result message...");
|
||||
let unlocked = stats_guard.check_achievements();
|
||||
println!("Newly unlocked achievements: {:?}", unlocked);
|
||||
unlocked
|
||||
};
|
||||
|
||||
// Emit achievement events for any newly unlocked achievements
|
||||
for achievement_id in &newly_unlocked {
|
||||
let info = get_achievement_info(achievement_id);
|
||||
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent {
|
||||
achievement: info,
|
||||
});
|
||||
}
|
||||
|
||||
// Save achievements after unlocking new ones
|
||||
if !newly_unlocked.is_empty() {
|
||||
println!("Saving newly unlocked achievements: {:?}", newly_unlocked);
|
||||
let app_handle = app.clone();
|
||||
let achievements_progress = stats.read().achievements.clone();
|
||||
|
||||
// Use Tauri's async runtime instead of tokio::spawn
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Spawned save task for achievements");
|
||||
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await {
|
||||
eprintln!("Failed to save achievements: {}", e);
|
||||
} else {
|
||||
println!("Achievement save task completed successfully");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let current_stats = stats.read().clone();
|
||||
let stats_event = StatsUpdateEvent {
|
||||
stats: current_stats,
|
||||
};
|
||||
let _ = app.emit("claude:stats", stats_event);
|
||||
|
||||
// Only emit error results - success content is already sent via Assistant message
|
||||
if subtype != "success" {
|
||||
if let Some(text) = result {
|
||||
@@ -480,7 +616,60 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
||||
emit_state_change(app, state, None);
|
||||
}
|
||||
|
||||
ClaudeMessage::User { .. } => {
|
||||
ClaudeMessage::User { message } => {
|
||||
// Increment message count for user messages
|
||||
stats.write().increment_messages();
|
||||
|
||||
// Extract text content from the message
|
||||
let message_text = message.content.iter()
|
||||
.filter_map(|block| match block {
|
||||
crate::types::ContentBlock::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(" ");
|
||||
|
||||
// Check achievements after user message
|
||||
let newly_unlocked = {
|
||||
let mut stats_guard = stats.write();
|
||||
println!("User sent message, checking achievements...");
|
||||
|
||||
// Check message-based achievements
|
||||
let mut unlocked = crate::achievements::check_message_achievements(
|
||||
&message_text,
|
||||
&mut stats_guard.achievements,
|
||||
);
|
||||
|
||||
// Check stats-based achievements
|
||||
let stats_unlocked = stats_guard.check_achievements();
|
||||
unlocked.extend(stats_unlocked);
|
||||
|
||||
unlocked
|
||||
};
|
||||
|
||||
// Emit achievement events for any newly unlocked achievements
|
||||
for achievement_id in &newly_unlocked {
|
||||
println!("User message unlocked achievement: {:?}", achievement_id);
|
||||
let info = get_achievement_info(achievement_id);
|
||||
let _ = app.emit("achievement:unlocked", AchievementUnlockedEvent {
|
||||
achievement: info,
|
||||
});
|
||||
}
|
||||
|
||||
// Save achievements after unlocking new ones
|
||||
if !newly_unlocked.is_empty() {
|
||||
println!("Saving newly unlocked achievements from user message");
|
||||
let app_handle = app.clone();
|
||||
let achievements_progress = stats.read().achievements.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = crate::achievements::save_achievements(&app_handle, &achievements_progress).await {
|
||||
eprintln!("Failed to save achievements: {}", e);
|
||||
} else {
|
||||
println!("Achievements saved after user message");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emit_state_change(app, CharacterState::Thinking, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user