generated from nhcarrigan/template
feat: add statistics
This commit is contained in:
@@ -2,6 +2,7 @@ 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;
|
||||||
|
|
||||||
const CONFIG_STORE_KEY: &str = "config";
|
const CONFIG_STORE_KEY: &str = "config";
|
||||||
@@ -72,3 +73,9 @@ 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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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;
|
||||||
@@ -35,6 +36,7 @@ pub fn run() {
|
|||||||
select_wsl_directory,
|
select_wsl_directory,
|
||||||
get_config,
|
get_config,
|
||||||
save_config,
|
save_config,
|
||||||
|
get_usage_stats,
|
||||||
send_windows_notification,
|
send_windows_notification,
|
||||||
send_simple_notification,
|
send_simple_notification,
|
||||||
send_windows_toast,
|
send_windows_toast,
|
||||||
|
|||||||
@@ -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<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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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};
|
||||||
|
|
||||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||||
@@ -72,6 +74,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,6 +85,7 @@ 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())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,10 +253,14 @@ 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();
|
||||||
|
|
||||||
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 +319,18 @@ 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
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<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 +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<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 +399,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 +448,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 +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" {
|
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
|
||||||
|
{
|
||||||
|
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
|
// 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 {
|
||||||
@@ -481,6 +554,8 @@ fn process_json_line(line: &str, app: &AppHandle) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ClaudeMessage::User { .. } => {
|
ClaudeMessage::User { .. } => {
|
||||||
|
// Increment message count for user messages
|
||||||
|
stats.write().increment_messages();
|
||||||
emit_state_change(app, CharacterState::Thinking, None);
|
emit_state_change(app, CharacterState::Thinking, None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<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]}
|
||||||
|
<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>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
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);
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
api_key: null,
|
api_key: null,
|
||||||
@@ -167,6 +169,20 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<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 +217,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 +241,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}
|
||||||
|
|||||||
@@ -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,16 @@
|
|||||||
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 +107,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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ 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 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 +77,9 @@ export async function initializeTauriListeners() {
|
|||||||
// Initialize notification rules
|
// Initialize notification rules
|
||||||
initializeNotificationRules();
|
initializeNotificationRules();
|
||||||
|
|
||||||
|
// Initialize stats listener
|
||||||
|
await initStatsListener();
|
||||||
|
|
||||||
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 +92,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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user