From 1ce43dcff83fbefcaa0943b767fed6f5c03d5d21 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 19 Jan 2026 17:01:26 -0800 Subject: [PATCH] feat: add statistics --- src-tauri/src/commands.rs | 7 + src-tauri/src/lib.rs | 2 + src-tauri/src/stats.rs | 182 +++++++++++++++++++++++++ src-tauri/src/types.rs | 10 ++ src-tauri/src/wsl_bridge.rs | 85 +++++++++++- src/app.css | 14 ++ src/lib/components/InputBar.svelte | 2 +- src/lib/components/StatsDisplay.svelte | 169 +++++++++++++++++++++++ src/lib/components/StatusBar.svelte | 31 +++++ src/lib/components/Terminal.svelte | 67 +++++++-- src/lib/stores/stats.ts | 130 ++++++++++++++++++ src/lib/tauri.ts | 5 + 12 files changed, 687 insertions(+), 17 deletions(-) create mode 100644 src-tauri/src/stats.rs create mode 100644 src/lib/components/StatsDisplay.svelte create mode 100644 src/lib/stores/stats.ts diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7532af1..4976260 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,6 +2,7 @@ use tauri::{AppHandle, State}; use tauri_plugin_store::StoreExt; use crate::config::{ClaudeStartOptions, HikariConfig}; +use crate::stats::UsageStats; use crate::wsl_bridge::SharedBridge; const CONFIG_STORE_KEY: &str = "config"; @@ -72,3 +73,9 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str Ok(()) } + +#[tauri::command] +pub async fn get_usage_stats(bridge: State<'_, SharedBridge>) -> Result { + let bridge = bridge.lock(); + Ok(bridge.get_stats()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 744d378..a88cd14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod commands; mod config; mod notifications; +mod stats; mod types; mod wsl_bridge; mod wsl_notifications; @@ -35,6 +36,7 @@ pub fn run() { select_wsl_directory, get_config, save_config, + get_usage_stats, send_windows_notification, send_simple_notification, send_windows_toast, diff --git a/src-tauri/src/stats.rs b/src-tauri/src/stats.rs new file mode 100644 index 0000000..ee35b96 --- /dev/null +++ b/src-tauri/src/stats.rs @@ -0,0 +1,182 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Instant; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UsageStats { + pub total_input_tokens: u64, + pub total_output_tokens: u64, + pub total_cost_usd: f64, + pub session_input_tokens: u64, + pub session_output_tokens: u64, + pub session_cost_usd: f64, + pub model: Option, + + // New fields + pub messages_exchanged: u64, + pub session_messages_exchanged: u64, + pub code_blocks_generated: u64, + pub session_code_blocks_generated: u64, + pub files_edited: u64, + pub session_files_edited: u64, + pub files_created: u64, + pub session_files_created: u64, + pub tools_usage: HashMap, + pub session_tools_usage: HashMap, + pub session_duration_seconds: u64, + #[serde(skip)] + pub session_start: Option, +} + +impl UsageStats { + pub fn new() -> Self { + Self::default() + } + + 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()); + } + + 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 + } +} + +// Pricing as of January 2025 +// https://www.anthropic.com/pricing +fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 { + let (input_price_per_million, output_price_per_million) = match model { + // Opus 4.5 + "claude-opus-4-5-20251101" => (15.0, 75.0), + + // Opus 4 + "claude-opus-4-20250514" => (15.0, 75.0), + + // Sonnet 4 + "claude-sonnet-4-20250514" => (3.0, 15.0), + + // Previous generation models + "claude-3-5-sonnet-20241022" => (3.0, 15.0), + "claude-3-5-sonnet-20240620" => (3.0, 15.0), + "claude-3-5-haiku-20241022" => (1.0, 5.0), + "claude-3-opus-20240229" => (15.0, 75.0), + "claude-3-sonnet-20240229" => (3.0, 15.0), + "claude-3-haiku-20240307" => (0.25, 1.25), + + // Default to Sonnet pricing if model unknown + _ => (3.0, 15.0), + }; + + let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price_per_million; + let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price_per_million; + + input_cost + output_cost +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatsUpdateEvent { + pub stats: UsageStats, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cost_calculation_sonnet() { + let cost = calculate_cost(1000, 2000, "claude-sonnet-4-20250514"); + // 1000 input * $3/M = $0.003 + // 2000 output * $15/M = $0.030 + // Total = $0.033 + assert!((cost - 0.033).abs() < 0.0001); + } + + #[test] + fn test_cost_calculation_opus() { + let cost = calculate_cost(1000, 2000, "claude-opus-4-20250514"); + // 1000 input * $15/M = $0.015 + // 2000 output * $75/M = $0.150 + // Total = $0.165 + assert!((cost - 0.165).abs() < 0.0001); + } + + #[test] + fn test_usage_stats_accumulation() { + let mut stats = UsageStats::new(); + stats.add_usage(1000, 2000, "claude-sonnet-4-20250514"); + + assert_eq!(stats.total_input_tokens, 1000); + assert_eq!(stats.total_output_tokens, 2000); + assert_eq!(stats.session_input_tokens, 1000); + assert_eq!(stats.session_output_tokens, 2000); + assert!((stats.total_cost_usd - 0.033).abs() < 0.0001); + } + + #[test] + fn test_session_reset() { + let mut stats = UsageStats::new(); + stats.add_usage(1000, 2000, "claude-sonnet-4-20250514"); + stats.reset_session(); + + assert_eq!(stats.total_input_tokens, 1000); + assert_eq!(stats.total_output_tokens, 2000); + assert_eq!(stats.session_input_tokens, 0); + assert_eq!(stats.session_output_tokens, 0); + assert_eq!(stats.session_cost_usd, 0.0); + assert!(stats.total_cost_usd > 0.0); + } +} \ No newline at end of file diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 108804e..ddcf67c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -1,5 +1,11 @@ use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UsageInfo { + pub input_tokens: u64, + pub output_tokens: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "snake_case")] pub enum CharacterState { @@ -87,6 +93,8 @@ pub enum ClaudeMessage { num_turns: Option, #[serde(default)] permission_denials: Option>, + #[serde(default)] + usage: Option, }, } @@ -97,6 +105,8 @@ pub struct AssistantMessageContent { pub model: Option, #[serde(default)] pub stop_reason: Option, + #[serde(default)] + pub usage: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 09b9bcb..337536b 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -10,6 +10,8 @@ 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}; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; @@ -72,6 +74,7 @@ pub struct WslBridge { working_directory: String, session_id: Option, mcp_config_file: Option, + stats: Arc>, } impl WslBridge { @@ -82,6 +85,7 @@ impl WslBridge { working_directory: String::new(), session_id: None, mcp_config_file: None, + stats: Arc::new(RwLock::new(UsageStats::new())), } } @@ -249,10 +253,14 @@ impl WslBridge { self.stdin = stdin; self.process = Some(child); + // Reset session stats when starting new session + self.stats.write().reset_session(); + 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 +319,18 @@ impl WslBridge { pub fn get_working_directory(&self) -> &str { &self.working_directory } + + pub fn get_stats(&self) -> UsageStats { + self.stats.read().clone() + } + + pub fn update_stats(&mut self, input_tokens: u64, output_tokens: u64, model: &str) { + self.stats.write().add_usage(input_tokens, output_tokens, model); + } + + pub fn reset_session_stats(&mut self) { + self.stats.write().reset_session(); + } } impl Default for WslBridge { @@ -319,13 +339,13 @@ impl Default for WslBridge { } } -fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle) { +fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>) { let reader = BufReader::new(stdout); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { - if let Err(e) = process_json_line(&line, &app) { + if let Err(e) = process_json_line(&line, &app, &stats) { eprintln!("Error processing line: {}", e); } } @@ -358,7 +378,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) { } } -fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> { +fn process_json_line(line: &str, app: &AppHandle, stats: &Arc>) -> Result<(), String> { let message: ClaudeMessage = serde_json::from_str(line) .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; @@ -379,12 +399,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 +448,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 +501,25 @@ 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 + { + stats.write().get_session_duration(); + } + + 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 { @@ -481,6 +554,8 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> { } ClaudeMessage::User { .. } => { + // Increment message count for user messages + stats.write().increment_messages(); emit_state_change(app, CharacterState::Thinking, None); } } diff --git a/src/app.css b/src/app.css index 7e6aa5c..5693b71 100644 --- a/src/app.css +++ b/src/app.css @@ -10,7 +10,14 @@ --accent-secondary: #ff6b9d; --text-primary: #ffffff; --text-secondary: #a0a0a0; + --text-tertiary: #6b7280; --border-color: #2a2a4a; + + /* Terminal specific colors */ + --terminal-user: #22d3ee; + --terminal-tool: #c084fc; + --terminal-tool-name: #ddd6fe; + --terminal-error: #f87171; } [data-theme="light"] { @@ -22,7 +29,14 @@ --accent-secondary: #ff6b9d; --text-primary: #1a1a2e; --text-secondary: #5a5a7a; + --text-tertiary: #9ca3af; --border-color: #d0d0e0; + + /* Terminal specific colors */ + --terminal-user: #0891b2; + --terminal-tool: #7c3aed; + --terminal-tool-name: #8b5cf6; + --terminal-error: #dc2626; } html, diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 025ca8d..3517c19 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -54,7 +54,7 @@ disabled={!isConnected || isSubmitting} rows={1} class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)] - rounded-lg text-white placeholder-gray-500 resize-none + rounded-lg text-[var(--text-primary)] placeholder-gray-500 resize-none focus:outline-none focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200" diff --git a/src/lib/components/StatsDisplay.svelte b/src/lib/components/StatsDisplay.svelte new file mode 100644 index 0000000..ad52130 --- /dev/null +++ b/src/lib/components/StatsDisplay.svelte @@ -0,0 +1,169 @@ + + +
+
+ Duration: + {$formattedStats.sessionDuration} +
+ +
+ Messages: + {$formattedStats.messagesSession} + / {$formattedStats.messagesTotal} +
+ +
+

Tokens & Cost

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

Activity

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

+ +

+ {#if showToolsBreakdown} +
+ {#each Object.entries($formattedStats.sessionToolsUsage).sort((a, b) => b[1] - a[1]) as [tool, count]} +
+ {tool}: + {count} +
+ {/each} +
+ {/if} +
+ {/if} + +
+ Model: + {$formattedStats.model} +
+
+ + \ No newline at end of file diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 2d3b7b2..2cca841 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -7,6 +7,7 @@ import { configStore, type HikariConfig } from "$lib/stores/config"; import type { ConnectionStatus } from "$lib/types/messages"; import { onMount } from "svelte"; + import StatsDisplay from "./StatsDisplay.svelte"; const DISCORD_URL = "https://chat.nhcarrigan.com"; @@ -16,6 +17,7 @@ let isConnecting = $state(false); let grantedToolsList: string[] = $state([]); let appVersion = $state(""); + let showStats = $state(false); let currentConfig: HikariConfig = $state({ model: null, api_key: null, @@ -167,6 +169,20 @@
+
+ +{#if showStats} + + +
showStats = false}>
+
+ +
+{/if} diff --git a/src/lib/components/Terminal.svelte b/src/lib/components/Terminal.svelte index e739a44..70be614 100644 --- a/src/lib/components/Terminal.svelte +++ b/src/lib/components/Terminal.svelte @@ -25,17 +25,17 @@ function getLineClass(type: string): string { switch (type) { case "user": - return "text-cyan-400"; + return "terminal-user"; case "assistant": - return "text-gray-100"; + return "terminal-assistant"; case "system": - return "text-gray-500 italic"; + return "terminal-system italic"; case "tool": - return "text-purple-400"; + return "terminal-tool"; case "error": - return "text-red-400"; + return "terminal-error"; default: - return "text-gray-300"; + return "terminal-default"; } } @@ -75,7 +75,7 @@
- Terminal + Terminal
{#if lines.length === 0} -
Waiting for Claude... Type a message below to start!
+
Waiting for Claude... Type a message below to start!
{:else} {#each lines as line (line.id)}
- {formatTime(line.timestamp)} + {formatTime(line.timestamp)} {#if getLinePrefix(line.type)} - {getLinePrefix(line.type)} + {getLinePrefix(line.type)} {/if} {#if line.toolName} - [{line.toolName}] + [{line.toolName}] {/if} {line.content}
@@ -107,4 +107,49 @@ scrollbar-width: thin; scrollbar-color: var(--border-color) var(--bg-terminal); } + + /* Terminal text colors that adapt to theme */ + .terminal-user { + color: var(--terminal-user, #22d3ee); + } + + .terminal-assistant { + color: var(--text-primary); + } + + .terminal-system { + color: var(--text-secondary); + } + + .terminal-tool { + color: var(--terminal-tool, #c084fc); + } + + .terminal-error { + color: var(--terminal-error, #f87171); + } + + .terminal-default { + color: var(--text-primary); + } + + .terminal-timestamp { + color: var(--text-tertiary, #6b7280); + } + + .terminal-prefix { + color: var(--text-secondary); + } + + .terminal-tool-name { + color: var(--terminal-tool-name, #ddd6fe); + } + + .terminal-waiting { + color: var(--text-secondary); + } + + .terminal-header-text { + color: var(--text-secondary); + } diff --git a/src/lib/stores/stats.ts b/src/lib/stores/stats.ts new file mode 100644 index 0000000..f456a74 --- /dev/null +++ b/src/lib/stores/stats.ts @@ -0,0 +1,130 @@ +import { writable, derived } from 'svelte/store'; +import { listen } from '@tauri-apps/api/event'; +import { invoke } from '@tauri-apps/api/core'; + +export interface UsageStats { + total_input_tokens: number; + total_output_tokens: number; + total_cost_usd: number; + session_input_tokens: number; + session_output_tokens: number; + session_cost_usd: number; + model: string | null; + + // New fields + messages_exchanged: number; + session_messages_exchanged: number; + code_blocks_generated: number; + session_code_blocks_generated: number; + files_edited: number; + session_files_edited: number; + files_created: number; + session_files_created: number; + tools_usage: Record; + session_tools_usage: Record; + session_duration_seconds: number; +} + +// Main stats store +export const stats = writable({ + total_input_tokens: 0, + total_output_tokens: 0, + total_cost_usd: 0, + session_input_tokens: 0, + session_output_tokens: 0, + session_cost_usd: 0, + model: null, + messages_exchanged: 0, + session_messages_exchanged: 0, + code_blocks_generated: 0, + session_code_blocks_generated: 0, + files_edited: 0, + session_files_edited: 0, + files_created: 0, + session_files_created: 0, + tools_usage: {}, + session_tools_usage: {}, + session_duration_seconds: 0, +}); + +// Derived store for formatted display values +export const formattedStats = derived(stats, ($stats) => { + const formatNumber = (num: number) => num.toLocaleString(); + const formatCost = (cost: number) => `$${cost.toFixed(4)}`; + const formatDuration = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } + }; + + return { + totalTokens: formatNumber($stats.total_input_tokens + $stats.total_output_tokens), + totalInputTokens: formatNumber($stats.total_input_tokens), + totalOutputTokens: formatNumber($stats.total_output_tokens), + totalCost: formatCost($stats.total_cost_usd), + sessionTokens: formatNumber($stats.session_input_tokens + $stats.session_output_tokens), + sessionInputTokens: formatNumber($stats.session_input_tokens), + sessionOutputTokens: formatNumber($stats.session_output_tokens), + sessionCost: formatCost($stats.session_cost_usd), + model: $stats.model || 'No model selected', + + // New formatted fields + messagesTotal: formatNumber($stats.messages_exchanged), + messagesSession: formatNumber($stats.session_messages_exchanged), + codeBlocksTotal: formatNumber($stats.code_blocks_generated), + codeBlocksSession: formatNumber($stats.session_code_blocks_generated), + filesEditedTotal: formatNumber($stats.files_edited), + filesEditedSession: formatNumber($stats.session_files_edited), + filesCreatedTotal: formatNumber($stats.files_created), + filesCreatedSession: formatNumber($stats.session_files_created), + sessionDuration: formatDuration($stats.session_duration_seconds), + toolsUsage: $stats.tools_usage, + sessionToolsUsage: $stats.session_tools_usage, + }; +}); + +// Note: Cost calculation is now done in the Rust backend + +// Initialize stats listener +export async function initStatsListener() { + // Listen for stats updates from the backend + await listen('claude:stats', (event) => { + const payload = event.payload as { stats: UsageStats }; + const { stats: newStats } = payload; + + // The backend already tracks all totals - just set the stats directly + stats.set(newStats); + }); + + // Load initial stats from backend + try { + const initialStats = await invoke('get_usage_stats'); + stats.set(initialStats); + } catch (error) { + console.error('Failed to load initial stats:', error); + } +} + +// Reset session stats (call when starting new session) +export function resetSessionStats() { + stats.update(current => ({ + ...current, + session_input_tokens: 0, + session_output_tokens: 0, + session_cost_usd: 0, + session_messages_exchanged: 0, + session_code_blocks_generated: 0, + session_files_edited: 0, + session_files_created: 0, + session_tools_usage: {}, + session_duration_seconds: 0, + })); +} \ No newline at end of file diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 849574f..4459158 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { claudeStore } from "$lib/stores/claude"; import { characterState } from "$lib/stores/character"; import { configStore } from "$lib/stores/config"; +import { initStatsListener, resetSessionStats } from "$lib/stores/stats"; import type { ConnectionStatus, PermissionPromptEvent } from "$lib/types/messages"; import type { CharacterState } from "$lib/types/states"; import { @@ -76,6 +77,9 @@ export async function initializeTauriListeners() { // Initialize notification rules initializeNotificationRules(); + // Initialize stats listener + await initStatsListener(); + const connectionUnlisten = await listen("claude:connection", async (event) => { const status = event.payload as ConnectionStatus; claudeStore.setConnectionStatus(status); @@ -88,6 +92,7 @@ export async function initializeTauriListeners() { characterState.setState("idle"); if (!hasConnectedThisSession) { hasConnectedThisSession = true; + resetSessionStats(); // Reset session stats on new connection await sendGreeting(); } } else if (status === "disconnected") {