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
+195 -6
View File
@@ -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);
}
}