feat: add statistics

This commit is contained in:
2026-01-19 17:01:26 -08:00
parent a8f98406e1
commit 1ce43dcff8
12 changed files with 687 additions and 17 deletions
+7
View File
@@ -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())
}
+2
View File
@@ -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,
+182
View File
@@ -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);
}
}
+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)]
+80 -5
View File
@@ -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
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,
+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"
+169
View File
@@ -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>
+31
View File
@@ -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}
+56 -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,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>
+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,
}));
}
+5
View File
@@ -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") {