diff --git a/Cargo.lock b/Cargo.lock index f88bbe8..5c0b70c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "tokemon" -version = "0.2.6" +version = "0.2.7" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b716706..a724bd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tokemon" -version = "0.2.6" +version = "0.2.7" edition = "2021" description = "Unified LLM token usage tracking across all providers" license = "MIT" diff --git a/README.md b/README.md index bce2ecf..7ebd046 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Unified token usage tracking across all your AI coding tools. `tokemon top` prov - **SQLite cache** — parsed data is cached for instant repeated runs and survives log rotation - **Budget pacemaker** — set daily/weekly/monthly spending limits with progress tracking - **Statusline mode** — compact one-line output for shell prompts and status bars (`tokemon statusline`) +- **Versioned status JSON** — stable machine-readable summaries for lightweight clients (`tokemon status --json`) - **Session breakdown** — per-session cost analysis across all providers (`tokemon sessions`) - **MCP server** — expose usage data to AI tools via Model Context Protocol (`tokemon mcp`) - **Two display modes** — compact one-row-per-day (default) or detailed per-model breakdown with responsive API Provider and Client columns @@ -111,6 +112,7 @@ tokemon [OPTIONS] Commands: top Live monitoring dashboard report Generate a static usage report (table, json, or csv) + status Emit a versioned machine-readable status document statusline Compact one-line output for shell prompts and status bars budget Show spending vs configured limits sessions Show per-session cost breakdown @@ -169,6 +171,23 @@ cost = true CLI flags always override config values. +### Local pricing overrides + +Create `~/.config/tokemon/pricing_override.json` to replace downloaded prices or add custom models: + +```json +{ + "custom-model": { + "input_cost_per_token": 0.000001, + "output_cost_per_token": 0.000004, + "cache_read_cost": 0.0000001, + "cache_creation_cost": 0.00000125 + } +} +``` + +Prices are in USD per token. Entries are merged by exact model key after the base pricing data is loaded. Supplied fields replace base values, while omitted fields keep their base values. The file is optional and changes take effect the next time `tokemon` starts. + ## Supported Providers | Provider | Log Location | Format | @@ -181,7 +200,7 @@ CLI flags always override config values. | Cline | VSCode globalStorage | JSON | | Roo Code | VSCode globalStorage | JSON | | Kilo Code | VSCode globalStorage | JSON | -| Copilot | VSCode workspaceStorage | JSON (stub) | +| Copilot | VSCode globalStorage telemetry directories | JSONL | | Cursor | `~/.config/tokscale/cursor-cache/usage*.csv` | CSV | | Qwen Code | `~/.qwen/tmp/{project}/session.json` | JSON | | Pi Agent | `~/.pi/agent/sessions/{project}/*.jsonl` | JSONL | diff --git a/src/cli.rs b/src/cli.rs index 193cd91..99ab53f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,6 @@ use chrono::NaiveDate; use clap::{Parser, Subcommand, ValueEnum}; +use std::path::PathBuf; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum DisplayMode { @@ -112,6 +113,8 @@ pub enum Frequency { pub enum Commands { /// Generate a static usage report (table, json, or csv) Report, + /// Emit a versioned machine-readable status document + Status, /// Compact one-line output for shell prompts and status bars Statusline, /// Show budget progress against configured limits @@ -120,6 +123,13 @@ pub enum Commands { Discover, /// Generate default config file at ~/.config/tokemon/config.toml Init, + /// Manage declarative local usage sources + Source { + #[command(subcommand)] + command: SourceCommands, + }, + /// Open the interactive configuration editor + Config, /// Show per-session cost breakdown Sessions { /// Show top N sessions by cost @@ -145,3 +155,12 @@ pub enum Commands { interval: u64, }, } + +#[derive(Subcommand)] +pub enum SourceCommands { + /// Validate a definition and preview discovered records + Validate { + /// TOML definition file to validate + definition: PathBuf, + }, +} diff --git a/src/config.rs b/src/config.rs index 7794481..b0702cd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io::ErrorKind; use std::path::PathBuf; use serde::{Deserialize, Serialize}; @@ -303,22 +304,59 @@ impl Config { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let content = toml::to_string_pretty(self)?; + let mut document = match fs::read_to_string(&path) { + Ok(existing) => toml::from_str::(&existing) + .unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new())), + Err(error) if error.kind() == ErrorKind::NotFound => { + toml::Value::Table(toml::map::Map::new()) + } + Err(error) => return Err(error.into()), + }; + let updated = toml::Value::try_from(self)?; + merge_toml_tables(&mut document, updated); + let content = toml::to_string_pretty(&document)?; let header = "# Tokemon configuration\n\ # Location: ~/.config/tokemon/config.toml\n\ #\n\ # Changes here affect default behavior.\n\ # CLI flags always override config values.\n\n"; - fs::write(&path, format!("{header}{content}"))?; + let temporary_path = path.with_extension(format!("toml.{}.tmp", std::process::id())); + fs::write(&temporary_path, format!("{header}{content}"))?; + match fs::rename(&temporary_path, &path) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::AlreadyExists => { + fs::remove_file(&path)?; + fs::rename(&temporary_path, &path)?; + } + Err(error) => { + let _ = fs::remove_file(&temporary_path); + return Err(error.into()); + } + } Ok(()) } pub fn config_path() -> PathBuf { - let config_dir = directories::ProjectDirs::from("", "", "tokemon").map_or_else( - || paths::home_dir().join(".config/tokemon"), - |d| d.config_dir().to_path_buf(), - ); - config_dir.join(CONFIG_FILENAME) + paths::config_dir().join(CONFIG_FILENAME) + } +} + +fn merge_toml_tables(existing: &mut toml::Value, updated: toml::Value) { + match updated { + toml::Value::Table(updated_table) => { + if let toml::Value::Table(existing_table) = existing { + for (key, value) in updated_table { + if let Some(existing_value) = existing_table.get_mut(&key) { + merge_toml_tables(existing_value, value); + } else { + existing_table.insert(key, value); + } + } + } else { + *existing = toml::Value::Table(updated_table); + } + } + updated => *existing = updated, } } @@ -422,4 +460,31 @@ mod tests { assert_eq!(validated.month_bucket_days, 1); assert_eq!(validated.tick_interval, 300); // Clamped to 300 } + + #[test] + fn merging_saved_values_preserves_unknown_keys() { + let mut existing: toml::Value = toml::from_str( + r#" + custom_option = "keep" + [columns] + custom_column = true + cost = false + "#, + ) + .expect("valid TOML"); + let updated: toml::Value = toml::from_str( + r#" + [columns] + cost = true + "#, + ) + .expect("valid TOML"); + + merge_toml_tables(&mut existing, updated); + + let table = existing.as_table().expect("root table"); + assert_eq!(table["custom_option"].as_str(), Some("keep")); + assert_eq!(table["columns"]["custom_column"].as_bool(), Some(true)); + assert_eq!(table["columns"]["cost"].as_bool(), Some(true)); + } } diff --git a/src/cost.rs b/src/cost.rs index 30af72a..0e7865a 100644 --- a/src/cost.rs +++ b/src/cost.rs @@ -12,6 +12,7 @@ use crate::types::Record; const PRICING_URL: &str = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; const CACHE_TTL_SECS: u64 = 3600; // 1 hour +const PRICING_OVERRIDE_FILENAME: &str = "pricing_override.json"; #[derive(Debug, Clone, Deserialize)] pub struct ModelPricing { @@ -29,6 +30,15 @@ pub struct PricingEngine { impl PricingEngine { pub fn load(offline: bool) -> Result { + let mut engine = Self::load_base(offline)?; + let override_path = Self::override_path(); + if let Err(e) = engine.apply_overrides_from_path(&override_path) { + eprintln!("[tokemon] Warning: {e}; using base prices"); + } + Ok(engine) + } + + fn load_base(offline: bool) -> Result { let cache_path = Self::cache_path(); // Check if cache is fresh @@ -41,9 +51,12 @@ impl PricingEngine { if let Ok(engine) = Self::parse_pricing(&data) { return Ok(engine); } - eprintln!("[tokemon] Warning: cached pricing data corrupt; costs will be $0.00"); + eprintln!("[tokemon] Warning: cached pricing data corrupt; no base prices loaded"); } - eprintln!("[tokemon] Warning: no cached pricing data and --offline specified; costs will be $0.00"); + eprintln!( + "[tokemon] Warning: no cached pricing data and --offline specified; \ + no base prices loaded" + ); return Ok(Self { models: HashMap::new(), }); @@ -71,7 +84,10 @@ impl PricingEngine { return Ok(engine); } } - eprintln!("[tokemon] Warning: failed to parse remote pricing: {e}; costs will be $0.00"); + eprintln!( + "[tokemon] Warning: failed to parse remote pricing: {e}; \ + no base prices loaded" + ); Ok(Self { models: HashMap::new(), }) @@ -88,7 +104,7 @@ impl PricingEngine { return Ok(engine); } } - eprintln!("[tokemon] Warning: failed to fetch pricing: {e}; costs will be $0.00"); + eprintln!("[tokemon] Warning: failed to fetch pricing: {e}; no base prices loaded"); Ok(Self { models: HashMap::new(), }) @@ -192,6 +208,43 @@ impl PricingEngine { paths::cache_dir().join("pricing.json") } + fn override_path() -> PathBuf { + paths::config_dir().join(PRICING_OVERRIDE_FILENAME) + } + + fn apply_overrides_from_path(&mut self, path: &Path) -> Result<()> { + let data = match fs::read_to_string(path) { + Ok(data) => data, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => { + return Err(TokemonError::Pricing(format!( + "failed to read pricing overrides from {}: {e}", + path.display() + ))); + } + }; + + let overrides: HashMap = + serde_json::from_str(&data).map_err(|e| { + TokemonError::Pricing(format!( + "failed to parse pricing overrides from {}: {e}", + path.display() + )) + })?; + self.merge_overrides(overrides); + Ok(()) + } + + fn merge_overrides(&mut self, overrides: HashMap) { + for (model, override_pricing) in overrides { + if let Some(base_pricing) = self.models.get_mut(&model) { + base_pricing.merge(&override_pricing); + } else { + self.models.insert(model, override_pricing); + } + } + } + fn read_cache(path: &Path) -> Option { fs::metadata(path) .ok()? @@ -230,6 +283,23 @@ impl PricingEngine { } } +impl ModelPricing { + fn merge(&mut self, overrides: &Self) { + if overrides.input_cost_per_token.is_some() { + self.input_cost_per_token = overrides.input_cost_per_token; + } + if overrides.output_cost_per_token.is_some() { + self.output_cost_per_token = overrides.output_cost_per_token; + } + if overrides.cache_read_cost.is_some() { + self.cache_read_cost = overrides.cache_read_cost; + } + if overrides.cache_creation_cost.is_some() { + self.cache_creation_cost = overrides.cache_creation_cost; + } + } +} + fn normalize_model_name(model: &str) -> String { let s = strip_deployment_suffix(model).to_lowercase(); let stripped = crate::display::strip_date_suffix(&s); @@ -322,6 +392,7 @@ fn provider_rank(route: Option<&str>, model: &str, candidate_route: Option<&str> #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; const DUMMY_JSON: &str = r#"{ "model-a": { @@ -340,6 +411,23 @@ mod tests { } }"#; + fn temporary_override_path(test_name: &str) -> PathBuf { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "tokemon-pricing-override-{test_name}-{}-{id}", + std::process::id() + )); + fs::create_dir_all(&dir).unwrap(); + dir.join(PRICING_OVERRIDE_FILENAME) + } + + fn remove_temporary_override(path: &Path) { + if let Some(parent) = path.parent() { + let _ = fs::remove_dir_all(parent); + } + } + const ROUTED_PRICING_JSON: &str = r#"{ "claude-opus-5": { "input_cost_per_token": 0.000005, @@ -545,6 +633,99 @@ mod tests { } } + #[test] + fn test_missing_pricing_override_is_no_op() { + let path = temporary_override_path("missing"); + let mut engine = PricingEngine::parse_pricing(DUMMY_JSON).unwrap(); + + engine.apply_overrides_from_path(&path).unwrap(); + + assert_eq!(engine.models.len(), 3); + assert_eq!(engine.models["model-a"].input_cost_per_token, Some(0.001)); + remove_temporary_override(&path); + } + + #[test] + fn test_pricing_override_merges_existing_model_fields() { + let path = temporary_override_path("existing"); + fs::write( + &path, + r#"{ + "model-a": { + "input_cost_per_token": 0.009, + "cache_read_cost": 0.0009 + } + }"#, + ) + .unwrap(); + let mut engine = PricingEngine::parse_pricing(DUMMY_JSON).unwrap(); + + engine.apply_overrides_from_path(&path).unwrap(); + + let pricing = &engine.models["model-a"]; + assert_eq!(pricing.input_cost_per_token, Some(0.009)); + assert_eq!(pricing.output_cost_per_token, Some(0.002)); + assert_eq!(pricing.cache_read_cost, Some(0.0009)); + remove_temporary_override(&path); + } + + #[test] + fn test_pricing_override_adds_custom_model() { + let path = temporary_override_path("custom"); + fs::write( + &path, + r#"{ + "custom-model": { + "input_cost_per_token": 0.004, + "output_cost_per_token": 0.008, + "cache_creation_input_token_cost": 0.005 + } + }"#, + ) + .unwrap(); + let mut engine = PricingEngine::parse_pricing(DUMMY_JSON).unwrap(); + + engine.apply_overrides_from_path(&path).unwrap(); + + let pricing = engine + .find_pricing("custom-model") + .expect("custom model should be added"); + assert_eq!(pricing.input_cost_per_token, Some(0.004)); + assert_eq!(pricing.output_cost_per_token, Some(0.008)); + assert_eq!(pricing.cache_creation_cost, Some(0.005)); + remove_temporary_override(&path); + } + + #[test] + fn test_malformed_pricing_override_keeps_base_prices() { + let path = temporary_override_path("malformed"); + fs::write(&path, r#"{"model-a": {"input_cost_per_token": "bad"}}"#).unwrap(); + let mut engine = PricingEngine::parse_pricing(DUMMY_JSON).unwrap(); + + let error = engine.apply_overrides_from_path(&path).unwrap_err(); + + assert!(error + .to_string() + .contains("failed to parse pricing overrides")); + assert_eq!(engine.models["model-a"].input_cost_per_token, Some(0.001)); + remove_temporary_override(&path); + } + + #[test] + fn test_unreadable_pricing_override_keeps_base_prices() { + let path = temporary_override_path("unreadable"); + fs::create_dir(&path).unwrap(); + let mut engine = PricingEngine::parse_pricing(DUMMY_JSON).unwrap(); + + let error = engine.apply_overrides_from_path(&path).unwrap_err(); + + assert!(error + .to_string() + .contains("failed to read pricing overrides")); + assert_eq!(engine.models["model-a"].input_cost_per_token, Some(0.001)); + remove_temporary_override(&path); + } + #[test] fn test_zero_cost_gets_repriced() { use chrono::Utc; diff --git a/src/display.rs b/src/display.rs index 7f35252..5ebef73 100644 --- a/src/display.rs +++ b/src/display.rs @@ -19,6 +19,7 @@ pub fn display_client(raw: &str) -> Cow<'static, str> { "roo-code" => "Roo Code".into(), "kilo-code" => "Kilo Code".into(), "copilot" => "GitHub Copilot".into(), + "continue" => "Continue".into(), "pi-agent" => "Pi Agent".into(), "kimi" => "Kimi".into(), "droid" => "Droid".into(), @@ -203,6 +204,7 @@ mod tests { assert_eq!(display_client("roo-code"), "Roo Code"); assert_eq!(display_client("opencode"), "OpenCode"); assert_eq!(display_client("copilot"), "GitHub Copilot"); + assert_eq!(display_client("continue"), "Continue"); // Unknown gets title-cased assert_eq!(display_client("my-tool"), "My Tool"); } diff --git a/src/main.rs b/src/main.rs index 6bed560..f561aaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,11 +33,11 @@ mod tui; mod types; use cache::Cache; -use cli::{Cli, Commands, Frequency}; +use cli::{Cli, Commands, Frequency, SourceCommands}; use config::Config; use pipeline::load_and_price; -use source::SourceSet; -use types::{Report, SessionReport}; +use source::{Source, SourceSet}; +use types::{Report, SessionReport, StatusCapabilities, StatusReport, StatusScope}; fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -45,11 +45,14 @@ fn main() -> anyhow::Result<()> { match &cli.command { Commands::Report => cmd_report(&cli, &config), + Commands::Status => cmd_status(&cli, &config), Commands::Discover => { cmd_discover(); Ok(()) } Commands::Init => cmd_init(), + Commands::Source { command } => cmd_source(command), + Commands::Config => tui::run_config(&config, cli.offline || config.offline), Commands::Statusline => cmd_statusline(&cli, &config), Commands::Budget => cmd_budget(&cli, &config), Commands::Sessions { top } => cmd_sessions(&cli, &config, *top), @@ -102,6 +105,38 @@ fn cmd_init() -> anyhow::Result<()> { Ok(()) } +fn cmd_source(command: &SourceCommands) -> anyhow::Result<()> { + match command { + SourceCommands::Validate { definition } => { + let source = source::custom::CustomSource::from_file(definition)?; + let files = source.discover_files(); + println!("Valid custom source: {}", source.display_name()); + println!("Schema version: {}", source.definition().schema_version); + println!("Discovered files: {}", files.len()); + if let Some(sample) = files.first() { + let records = source.parse_file(sample)?; + println!("Preview file: {}", sample.display()); + println!("Parsed records: {}", records.len()); + for record in records.iter().take(5) { + println!( + " {} | model={} | input={} | output={} | cache_read={} | cache_write={} | thinking={}", + record.timestamp.to_rfc3339(), + record.model.as_deref().unwrap_or("unknown"), + record.input_tokens, + record.output_tokens, + record.cache_read_tokens, + record.cache_creation_tokens, + record.thinking_tokens, + ); + } + } else { + println!("No matching files found."); + } + } + } + Ok(()) +} + // --- Shared helpers for command handlers --- /// Compute the start date for a given frequency. @@ -211,6 +246,61 @@ fn cmd_report(cli: &Cli, config: &Config) -> anyhow::Result<()> { Ok(()) } +fn cmd_status(cli: &Cli, config: &Config) -> anyhow::Result<()> { + let freq = cli.frequency; + let entries = load_and_price( + &pipeline::PipelineOptions::from_cli_config(cli, config), + false, + )?; + + let mut summaries = match freq { + Frequency::Weekly => rollup::aggregate_weekly(&entries), + Frequency::Monthly => rollup::aggregate_monthly(&entries), + Frequency::Daily => rollup::aggregate_daily(&entries), + }; + summaries.sort_unstable_by_key(|summary| summary.date); + + let providers: Vec = entries + .iter() + .map(|entry| entry.provider.to_string()) + .collect::>() + .into_iter() + .collect(); + let total_cost = summaries.iter().map(|summary| summary.total_cost).sum(); + let total_tokens = entries.iter().map(types::Record::total_tokens).sum(); + let total_requests = summaries.iter().map(|summary| summary.total_requests).sum(); + + let status = StatusReport { + schema_version: 1, + generated_at: Utc::now(), + state: if entries.is_empty() { + "empty".to_string() + } else { + "populated".to_string() + }, + scope: StatusScope { + frequency: frequency_label(freq).to_string(), + since: cli.since, + until: cli.until, + }, + providers, + summaries, + total_cost, + total_tokens, + total_requests, + capabilities: StatusCapabilities { + cost: !cli.no_cost && !config.no_cost, + date_filters: true, + provider_filters: true, + periodic_summaries: true, + session_view: true, + }, + }; + + render::print_status_json(&status); + Ok(()) +} + fn cmd_statusline(cli: &Cli, config: &Config) -> anyhow::Result<()> { let freq = cli.frequency; let since = frequency_since(freq); @@ -356,3 +446,11 @@ fn format_provider_count(count: usize) -> String { format!("{count} providers") } } + +fn frequency_label(freq: Frequency) -> &'static str { + match freq { + Frequency::Daily => "daily", + Frequency::Weekly => "weekly", + Frequency::Monthly => "monthly", + } +} diff --git a/src/paths.rs b/src/paths.rs index 9d2ba0c..e19b4c1 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -27,6 +27,13 @@ pub fn cache_dir() -> PathBuf { ) } +pub fn config_dir() -> PathBuf { + directories::ProjectDirs::from("", "", "tokemon").map_or_else( + || home_dir().join(".config/tokemon"), + |d| d.config_dir().to_path_buf(), + ) +} + pub fn vscode_global_storage_dirs() -> Vec { let base = if cfg!(target_os = "macos") { home_dir().join("Library/Application Support") diff --git a/src/render/json.rs b/src/render/json.rs index c465ecc..99ef6f2 100644 --- a/src/render/json.rs +++ b/src/render/json.rs @@ -1,4 +1,4 @@ -use crate::types::{Report, SessionReport}; +use crate::types::{Report, SessionReport, StatusReport}; pub fn print_json(report: &Report) { match serde_json::to_string_pretty(report) { @@ -13,3 +13,10 @@ pub fn print_sessions_json(report: &SessionReport) { Err(e) => eprintln!("[tokemon] Error serializing sessions: {e}"), } } + +pub fn print_status_json(status: &StatusReport) { + match serde_json::to_string_pretty(status) { + Ok(json) => println!("{json}"), + Err(e) => eprintln!("[tokemon] Error serializing status: {e}"), + } +} diff --git a/src/render/mod.rs b/src/render/mod.rs index 1c95bab..39bf4a4 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -5,7 +5,7 @@ pub mod table; pub use csv::{print_csv_breakdown, print_csv_compact, print_csv_sessions}; pub use helpers::{format_cost, format_tokens_short}; -pub use json::{print_json, print_sessions_json}; +pub use json::{print_json, print_sessions_json, print_status_json}; pub use table::{ print_budget, print_discover, print_sessions_table, print_statusline, print_table, }; diff --git a/src/source/continue_dev.rs b/src/source/continue_dev.rs new file mode 100644 index 0000000..1bf75c5 --- /dev/null +++ b/src/source/continue_dev.rs @@ -0,0 +1,215 @@ +use std::borrow::Cow; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::{Result, TokemonError}; +use crate::paths; +use crate::timestamp; +use crate::types::Record; + +pub struct ContinueSource { + base_dir: PathBuf, +} + +impl Default for ContinueSource { + fn default() -> Self { + Self::new() + } +} + +impl ContinueSource { + pub fn new() -> Self { + Self { + base_dir: paths::home_dir().join(".continue/dev_data"), + } + } +} + +#[derive(Deserialize)] +struct TokenEvent { + timestamp: Option, + model: Option, + provider: Option, + #[serde(rename = "promptTokens")] + prompt_tokens: Option, + #[serde(rename = "generatedTokens")] + generated_tokens: Option, +} + +#[must_use] +fn provider_prefix(provider: &str) -> &str { + match provider { + "vertexai" | "google-vertex" => "vertexai.", + "openai" => "openai/", + "anthropic" => "anthropic/", + "gemini" | "google" => "google/", + "bedrock" | "aws-bedrock" => "bedrock/", + "azure" | "azure-openai" => "azure/", + _ => "", + } +} + +#[must_use] +fn qualify_model(model: String, provider: Option<&str>) -> String { + let prefix = provider.map(provider_prefix).unwrap_or_default(); + if prefix.is_empty() + || model.contains('/') + || model.starts_with("vertexai.") + || model.starts_with("anthropic.") + { + model + } else { + format!("{prefix}{model}") + } +} + +impl super::Source for ContinueSource { + fn name(&self) -> &'static str { + "continue" + } + + fn display_name(&self) -> &'static str { + "Continue" + } + + fn data_dir(&self) -> PathBuf { + self.base_dir.clone() + } + + fn discover_files(&self) -> Vec { + let Ok(version_dirs) = fs::read_dir(&self.base_dir) else { + return Vec::new(); + }; + let mut files = version_dirs + .filter_map(std::result::Result::ok) + .filter_map(|entry| { + entry + .file_type() + .ok() + .filter(std::fs::FileType::is_dir) + .map(|_| entry.path().join("tokensGenerated.jsonl")) + }) + .filter(|path| path.is_file()) + .collect::>(); + files.sort(); + files + } + + fn parse_file(&self, path: &Path) -> Result> { + let file = fs::File::open(path).map_err(TokemonError::Io)?; + let reader = BufReader::with_capacity(64 * 1024, file); + let mut io_errors = 0u64; + let mut json_errors = 0u64; + let mut invalid_events = 0u64; + let mut entries = Vec::new(); + + for line in reader.lines() { + let line = match line { + Ok(line) => line, + Err(error) => { + if io_errors == 0 { + eprintln!( + "[tokemon] Warning: I/O error reading {}: {error}", + path.display() + ); + } + io_errors += 1; + continue; + } + }; + + if !line.contains("\"promptTokens\"") || !line.contains("\"generatedTokens\"") { + continue; + } + + let Ok(event) = serde_json::from_str::(&line) else { + json_errors += 1; + continue; + }; + + let Some(parsed_timestamp) = event + .timestamp + .as_deref() + .and_then(timestamp::parse_timestamp) + else { + invalid_events += 1; + continue; + }; + let (Some(prompt_tokens), Some(generated_tokens)) = + (event.prompt_tokens, event.generated_tokens) + else { + invalid_events += 1; + continue; + }; + if prompt_tokens == 0 && generated_tokens == 0 { + continue; + } + + entries.push(Record { + timestamp: parsed_timestamp, + provider: Cow::Borrowed("continue"), + model: event + .model + .map(|model| qualify_model(model, event.provider.as_deref())), + input_tokens: prompt_tokens, + output_tokens: generated_tokens, + cache_read_tokens: 0, + cache_creation_tokens: 0, + thinking_tokens: 0, + cost_usd: None, + message_id: None, + request_id: None, + session_id: None, + }); + } + + if io_errors > 0 { + eprintln!( + "[tokemon] Warning: skipped {io_errors} lines in {} due to I/O errors", + path.display() + ); + } + if json_errors > 0 { + eprintln!( + "[tokemon] Warning: skipped {json_errors} malformed JSON lines in {}", + path.display() + ); + } + if invalid_events > 0 { + eprintln!( + "[tokemon] Warning: skipped {invalid_events} incomplete usage events in {}", + path.display() + ); + } + + Ok(entries) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qualifies_known_api_providers() { + assert_eq!( + qualify_model("claude-sonnet-4".to_string(), Some("anthropic")), + "anthropic/claude-sonnet-4" + ); + assert_eq!( + qualify_model("gemini-2.5-pro".to_string(), Some("vertexai")), + "vertexai.gemini-2.5-pro" + ); + assert_eq!( + qualify_model("openai/gpt-4o".to_string(), Some("openai")), + "openai/gpt-4o" + ); + assert_eq!( + qualify_model("custom-model".to_string(), Some("custom")), + "custom-model" + ); + } +} diff --git a/src/source/copilot.rs b/src/source/copilot.rs index 0920a05..28cbba8 100644 --- a/src/source/copilot.rs +++ b/src/source/copilot.rs @@ -1,7 +1,11 @@ +use std::borrow::Cow; +use std::fs::File; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use crate::error::Result; use crate::paths; +use crate::timestamp; use crate::types::Record; pub struct CopilotSource; @@ -29,43 +33,238 @@ impl super::Source for CopilotSource { fn data_dir(&self) -> PathBuf { let storage_dirs = paths::vscode_global_storage_dirs(); - if let Some(first) = storage_dirs.first() { - // Copilot stores in workspaceStorage, not globalStorage - first - .parent() - .map(|p| p.join("workspaceStorage")) - .unwrap_or_default() - } else { - PathBuf::from("(VSCode workspaceStorage)") - } + storage_dirs.first().map_or_else( + || PathBuf::from("(editor telemetry)"), + std::clone::Clone::clone, + ) } fn discover_files(&self) -> Vec { - // Structure: workspaceStorage/{hash}/chatSessions/{uuid}.json - // Must target chatSessions/ specifically to avoid workspace.json etc. - let storage_dirs = paths::vscode_global_storage_dirs(); let mut files = Vec::new(); + for root in telemetry_roots() { + files.extend(super::discover::walk_by_ext(&root, "jsonl", 4)); + } + files.sort(); + files.dedup(); + files + } - for storage_dir in storage_dirs { - if let Some(parent) = storage_dir.parent() { - let ws_storage = parent.join("workspaceStorage"); - let Ok(workspaces) = std::fs::read_dir(&ws_storage) else { - continue; - }; - for ws in workspaces.filter_map(std::result::Result::ok) { - let chat_dir = ws.path().join("chatSessions"); - if chat_dir.is_dir() { - files.extend(super::discover::collect_by_ext(&chat_dir, "json")); + #[allow(clippy::too_many_lines)] + fn parse_file(&self, path: &Path) -> Result> { + let file = File::open(path)?; + let reader = BufReader::with_capacity(64 * 1024, file); + let session_id = timestamp::extract_session_id(path); + let mut malformed_logged = false; + let mut incomplete = 0usize; + let mut records = Vec::new(); + + for line in reader.lines().map_while(std::result::Result::ok) { + if !line.contains("input_tokens") && !line.contains("prompt_tokens") { + continue; + } + let value: serde_json::Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(error) => { + if !malformed_logged { + eprintln!( + "[tokemon] Warning: skipped malformed telemetry JSON in {}: {}", + path.display(), + error + ); + malformed_logged = true; } + continue; } + }; + + let Some(timestamp) = timestamp_value(&value) else { + incomplete += 1; + continue; + }; + let input_tokens = first_u64( + &value, + &[ + "gen_ai.usage.input_tokens", + "gen_ai.usage.prompt_tokens", + "input_tokens", + "prompt_tokens", + ], + ); + let output_tokens = first_u64( + &value, + &[ + "gen_ai.usage.output_tokens", + "gen_ai.usage.completion_tokens", + "output_tokens", + "completion_tokens", + ], + ); + let cache_read_tokens = first_u64( + &value, + &[ + "gen_ai.usage.cache_read_input_tokens", + "gen_ai.usage.cache_read_tokens", + "cache_read_input_tokens", + "cache_read_tokens", + ], + ); + let cache_creation_tokens = first_u64( + &value, + &[ + "gen_ai.usage.cache_creation_input_tokens", + "gen_ai.usage.cache_write_input_tokens", + "gen_ai.usage.cache_creation_tokens", + "cache_creation_tokens", + ], + ); + let thinking_tokens = first_u64( + &value, + &["gen_ai.usage.reasoning_tokens", "reasoning_tokens"], + ); + if input_tokens + + output_tokens + + cache_read_tokens + + cache_creation_tokens + + thinking_tokens + == 0 + { + incomplete += 1; + continue; } + + let model = first_string( + &value, + &["gen_ai.request.model", "gen_ai.response.model", "model"], + ); + let message_id = first_string(&value, &["message_id", "messageId"]); + let request_id = + first_string(&value, &["gen_ai.request.id", "request_id", "requestId"]); + let record_session = first_string( + &value, + &["session_id", "sessionId", "copilot_chat.session_id"], + ) + .or_else(|| session_id.clone()); + + records.push(Record { + timestamp, + provider: Cow::Borrowed("copilot"), + model, + input_tokens, + output_tokens, + cache_read_tokens, + cache_creation_tokens, + thinking_tokens, + cost_usd: None, + message_id, + request_id, + session_id: record_session, + }); } - files + + if incomplete > 0 { + eprintln!( + "[tokemon] Warning: skipped {incomplete} incomplete telemetry records in {}", + path.display() + ); + } + Ok(records) } +} + +fn telemetry_roots() -> Vec { + paths::vscode_global_storage_dirs() + .into_iter() + .flat_map(|root| { + ["github.copilot-chat", "github.copilot"] + .into_iter() + .map(move |extension| root.join(extension)) + }) + .filter(|root| root.is_dir()) + .collect() +} + +fn value_at<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { + path.split('.') + .try_fold(value, |current, key| current.get(key)) +} + +fn first_string(value: &serde_json::Value, paths: &[&str]) -> Option { + paths.iter().find_map(|path| { + value_at(value, path) + .and_then(serde_json::Value::as_str) + .map(ToOwned::to_owned) + }) +} + +fn first_u64(value: &serde_json::Value, paths: &[&str]) -> u64 { + paths + .iter() + .find_map(|path| { + value_at(value, path).and_then(|value| { + value + .as_u64() + .or_else(|| value.as_str().and_then(|raw| raw.parse().ok())) + }) + }) + .unwrap_or(0) +} + +fn timestamp_value(value: &serde_json::Value) -> Option> { + for path in ["timestamp", "time", "time_unix_nano", "timeUnixNano"] { + let Some(candidate) = value_at(value, path) else { + continue; + }; + if let Some(raw) = candidate.as_str() { + if let Some(parsed) = timestamp::parse_timestamp(raw) { + return Some(parsed); + } + } + if let Some(raw) = candidate.as_i64() { + if raw > 1_000_000_000_000_000 { + return chrono::DateTime::from_timestamp_millis(raw / 1_000_000); + } + if let Some(parsed) = timestamp::parse_timestamp_numeric(raw) { + return Some(parsed); + } + } + if let Some(raw) = candidate.as_u64() { + if raw > 1_000_000_000_000_000 { + return chrono::DateTime::from_timestamp_millis((raw / 1_000_000) as i64); + } + if let Some(parsed) = timestamp::parse_timestamp(&raw.to_string()) { + return Some(parsed); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::CopilotSource; + use crate::source::Source; + use std::io::Write; + + #[test] + fn parses_nested_telemetry_and_skips_incomplete_lines() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("session.jsonl"); + let mut file = std::fs::File::create(&path).unwrap(); + writeln!( + file, + r#"{{"timestamp":"2026-02-20T10:00:00Z","session_id":"s-1","gen_ai":{{"request":{{"model":"model-x","id":"req-1"}},"usage":{{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":5}}}}}}"# + ) + .unwrap(); + writeln!(file, "{{\"input_tokens\":10}}").unwrap(); + writeln!(file, "not json").unwrap(); - fn parse_file(&self, _path: &Path) -> Result> { - // Copilot doesn't store token counts in its session files. - // Would need tiktoken for estimation. For PoC, return empty. - Ok(Vec::new()) + let records = CopilotSource::new().parse_file(&path).unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].input_tokens, 10); + assert_eq!(records[0].output_tokens, 20); + assert_eq!(records[0].cache_read_tokens, 5); + assert_eq!(records[0].model.as_deref(), Some("model-x")); + assert_eq!(records[0].session_id.as_deref(), Some("s-1")); + assert_eq!(records[0].request_id.as_deref(), Some("req-1")); } } diff --git a/src/source/custom.rs b/src/source/custom.rs new file mode 100644 index 0000000..7ba365f --- /dev/null +++ b/src/source/custom.rs @@ -0,0 +1,422 @@ +use std::borrow::Cow; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::paths; +use crate::timestamp; +use crate::types::Record; + +const DEFINITION_DIR: &str = "sources"; +const MAX_DEPTH: usize = 8; + +/// Versioned, data-only definition for a local JSONL source. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct CustomSourceDefinition { + pub schema_version: u32, + pub name: String, + pub display_name: Option, + pub roots: Vec, + pub extension: String, + pub max_depth: usize, + pub format: String, + pub provider: Option, + pub model_prefix: String, + pub timestamp: String, + pub model: String, + pub input_tokens: String, + pub output_tokens: String, + pub cache_read_tokens: String, + pub cache_creation_tokens: String, + pub thinking_tokens: String, + pub session_id: String, + pub message_id: String, + pub request_id: String, +} + +impl Default for CustomSourceDefinition { + fn default() -> Self { + Self { + schema_version: 1, + name: String::new(), + display_name: None, + roots: Vec::new(), + extension: "jsonl".to_string(), + max_depth: 3, + format: "jsonl".to_string(), + provider: None, + model_prefix: String::new(), + timestamp: "timestamp".to_string(), + model: "model".to_string(), + input_tokens: "usage.input_tokens".to_string(), + output_tokens: "usage.output_tokens".to_string(), + cache_read_tokens: String::new(), + cache_creation_tokens: String::new(), + thinking_tokens: String::new(), + session_id: String::new(), + message_id: String::new(), + request_id: String::new(), + } + } +} + +/// A validated custom source loaded from a local definition. +pub struct CustomSource { + definition: CustomSourceDefinition, + roots: Vec, + name: &'static str, + display_name: &'static str, +} + +impl CustomSource { + /// Build a source after validating its data-only definition. + pub fn from_definition(definition: CustomSourceDefinition) -> anyhow::Result { + validate_definition(&definition)?; + let roots = definition + .roots + .iter() + .map(|root| expand_root(root)) + .collect::>>()?; + let name = leak_string(definition.name.clone()); + let display_name = leak_string( + definition + .display_name + .clone() + .unwrap_or_else(|| definition.name.clone()), + ); + Ok(Self { + definition, + roots, + name, + display_name, + }) + } + + /// Load and validate one TOML definition file. + pub fn from_file(path: &Path) -> anyhow::Result { + let content = fs::read_to_string(path)?; + let definition = toml::from_str(&content)?; + Self::from_definition(definition) + } + + /// Return the validated definition used by this source. + #[must_use] + pub const fn definition(&self) -> &CustomSourceDefinition { + &self.definition + } + + /// Return all locally configured custom sources, skipping invalid files. + #[must_use] + pub fn load_configured() -> Vec { + let definition_dir = paths::config_dir().join(DEFINITION_DIR); + crate::source::discover::walk_by_ext(&definition_dir, "toml", 2) + .into_iter() + .filter_map(|path| match Self::from_file(&path) { + Ok(source) => Some(source), + Err(error) => { + eprintln!( + "[tokemon] Warning: invalid custom source {}: {error}", + path.display() + ); + None + } + }) + .collect() + } + + fn parse_jsonl(&self, path: &Path) -> std::io::Result> { + let file = fs::File::open(path)?; + let reader = BufReader::with_capacity(64 * 1024, file); + let session_from_path = timestamp::extract_session_id(path); + let mut skipped = 0u64; + let mut records = Vec::new(); + + for line in reader.lines() { + let line = match line { + Ok(line) => line, + Err(error) => { + skipped += 1; + eprintln!( + "[tokemon] Warning: I/O error reading {}: {error}", + path.display() + ); + continue; + } + }; + let Ok(value) = serde_json::from_str::(&line) else { + skipped += 1; + continue; + }; + match self.record_from_value(&value, session_from_path.clone()) { + Some(record) => records.push(record), + None => skipped += 1, + } + } + + if skipped > 0 { + eprintln!( + "[tokemon] Warning: skipped {skipped} custom-source records in {}", + path.display() + ); + } + Ok(records) + } + + fn record_from_value( + &self, + value: &Value, + session_from_path: Option, + ) -> Option { + let timestamp = value_at_path(value, &self.definition.timestamp) + .and_then(value_as_string) + .and_then(|raw| timestamp::parse_timestamp(&raw))?; + let model = value_at_path(value, &self.definition.model) + .and_then(value_as_string) + .map(|model| { + if self.definition.model_prefix.is_empty() { + model + } else { + format!("{}{model}", self.definition.model_prefix) + } + }); + let provider = self + .definition + .provider + .as_deref() + .unwrap_or(self.name) + .to_string(); + + Some(Record { + timestamp, + provider: Cow::Owned(provider), + model, + input_tokens: mapped_u64(value, &self.definition.input_tokens), + output_tokens: mapped_u64(value, &self.definition.output_tokens), + cache_read_tokens: mapped_u64(value, &self.definition.cache_read_tokens), + cache_creation_tokens: mapped_u64(value, &self.definition.cache_creation_tokens), + thinking_tokens: mapped_u64(value, &self.definition.thinking_tokens), + cost_usd: None, + message_id: mapped_string(value, &self.definition.message_id), + request_id: mapped_string(value, &self.definition.request_id), + session_id: mapped_string(value, &self.definition.session_id).or(session_from_path), + }) + } +} + +impl super::Source for CustomSource { + fn name(&self) -> &'static str { + self.name + } + + fn display_name(&self) -> &'static str { + self.display_name + } + + fn data_dir(&self) -> PathBuf { + self.roots.first().cloned().unwrap_or_default() + } + + fn discover_files(&self) -> Vec { + self.roots + .iter() + .flat_map(|root| { + super::discover::walk_by_ext( + root, + self.definition.extension.trim_start_matches('.'), + self.definition.max_depth, + ) + }) + .collect() + } + + fn parse_file(&self, path: &Path) -> crate::error::Result> { + self.parse_jsonl(path) + .map_err(crate::error::TokemonError::Io) + } +} + +fn validate_definition(definition: &CustomSourceDefinition) -> anyhow::Result<()> { + anyhow::ensure!( + definition.schema_version == 1, + "unsupported custom source schema version {}; expected 1", + definition.schema_version + ); + anyhow::ensure!( + is_safe_name(&definition.name), + "name must contain only letters, numbers, '.', '_' or '-'" + ); + anyhow::ensure!( + !definition.roots.is_empty(), + "at least one root path is required" + ); + anyhow::ensure!( + definition.format.eq_ignore_ascii_case("jsonl"), + "format must be jsonl" + ); + let extension = definition.extension.trim_start_matches('.'); + anyhow::ensure!( + !extension.is_empty() && extension.chars().all(|c| c.is_ascii_alphanumeric()), + "extension must be a simple file extension" + ); + anyhow::ensure!( + (1..=MAX_DEPTH).contains(&definition.max_depth), + "max_depth must be between 1 and {MAX_DEPTH}" + ); + anyhow::ensure!( + !definition.timestamp.trim().is_empty(), + "timestamp mapping is required" + ); + for (label, mapping) in [ + ("timestamp", definition.timestamp.as_str()), + ("model", definition.model.as_str()), + ("input_tokens", definition.input_tokens.as_str()), + ("output_tokens", definition.output_tokens.as_str()), + ("cache_read_tokens", definition.cache_read_tokens.as_str()), + ( + "cache_creation_tokens", + definition.cache_creation_tokens.as_str(), + ), + ("thinking_tokens", definition.thinking_tokens.as_str()), + ("session_id", definition.session_id.as_str()), + ("message_id", definition.message_id.as_str()), + ("request_id", definition.request_id.as_str()), + ] { + anyhow::ensure!( + valid_mapping(mapping), + "{label} mapping must contain only dotted JSON object keys" + ); + } + anyhow::ensure!( + !definition.input_tokens.trim().is_empty() + || !definition.output_tokens.trim().is_empty() + || !definition.cache_read_tokens.trim().is_empty() + || !definition.cache_creation_tokens.trim().is_empty() + || !definition.thinking_tokens.trim().is_empty(), + "at least one token mapping is required" + ); + for root in &definition.roots { + let expanded = expand_root(root)?; + anyhow::ensure!( + expanded.is_absolute(), + "root paths must be absolute or start with '~/'" + ); + } + Ok(()) +} + +fn expand_root(root: &str) -> anyhow::Result { + let root = root.trim(); + anyhow::ensure!(!root.is_empty(), "root paths cannot be empty"); + if root == "~" || root.starts_with("~/") { + Ok(paths::home_dir().join(root.strip_prefix("~/").unwrap_or(""))) + } else { + Ok(PathBuf::from(root)) + } +} + +fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + if path.trim().is_empty() { + return None; + } + path.split('.') + .try_fold(value, |current, key| current.get(key)) +} + +fn value_as_string(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +fn mapped_string(value: &Value, path: &str) -> Option { + value_at_path(value, path).and_then(value_as_string) +} + +fn mapped_u64(value: &Value, path: &str) -> u64 { + value_at_path(value, path) + .and_then(|value| match value { + Value::Number(number) => number.as_u64(), + Value::String(string) => string.parse().ok(), + _ => None, + }) + .unwrap_or(0) +} + +fn is_safe_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character)) +} + +fn valid_mapping(mapping: &str) -> bool { + mapping.is_empty() + || mapping.split('.').all(|segment| { + !segment.is_empty() + && segment + .chars() + .all(|character| character.is_ascii_alphanumeric() || "_-".contains(character)) + }) +} + +fn leak_string(value: String) -> &'static str { + Box::leak(value.into_boxed_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn parses_nested_jsonl_mappings_and_skips_bad_lines() { + let directory = tempfile::tempdir().expect("temporary directory"); + let file = directory.path().join("events.jsonl"); + fs::write( + &file, + "{\"at\":\"2026-08-09T10:00:00Z\",\"model\":\"model-a\",\"usage\":{\"in\":12,\"out\":8}}\nnot-json\n", + ) + .expect("fixture write"); + let source = CustomSource::from_definition(CustomSourceDefinition { + name: "private".to_string(), + roots: vec![directory.path().display().to_string()], + timestamp: "at".to_string(), + model: "model".to_string(), + input_tokens: "usage.in".to_string(), + output_tokens: "usage.out".to_string(), + ..CustomSourceDefinition::default() + }) + .expect("valid definition"); + + let records = source.parse_jsonl(&file).expect("parse succeeds"); + assert_eq!(records.len(), 1); + assert_eq!(records[0].provider, "private"); + assert_eq!(records[0].total_tokens(), 20); + assert_eq!(records[0].session_id.as_deref(), Some("events")); + } + + #[test] + fn rejects_future_schema_and_unbounded_paths() { + let future = CustomSourceDefinition { + schema_version: 2, + roots: vec!["/tmp".to_string()], + ..CustomSourceDefinition::default() + }; + assert!(CustomSource::from_definition(future).is_err()); + + let unbounded = CustomSourceDefinition { + name: "private".to_string(), + roots: vec!["/tmp".to_string()], + max_depth: MAX_DEPTH + 1, + ..CustomSourceDefinition::default() + }; + assert!(CustomSource::from_definition(unbounded).is_err()); + } +} diff --git a/src/source/mod.rs b/src/source/mod.rs index c12b4b1..78225bb 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -3,8 +3,10 @@ pub mod claude_code; pub mod cline; pub mod cline_format; pub mod codex; +pub mod continue_dev; pub mod copilot; pub mod cursor; +pub mod custom; pub mod discover; pub mod droid; pub mod gemini; @@ -75,26 +77,31 @@ impl Default for SourceSet { impl SourceSet { pub fn new() -> Self { - Self { - providers: vec![ - Box::new(claude_code::ClaudeCodeSource::new()), - Box::new(codex::CodexSource::new()), - Box::new(gemini::GeminiSource::new()), - Box::new(opencode::OpenCodeSource::new()), - Box::new(amp::AmpSource::new()), - Box::new(cline::ClineSource::new()), - Box::new(roo_code::RooCodeSource::new()), - Box::new(kilo_code::KiloCodeSource::new()), - Box::new(copilot::CopilotSource::new()), - Box::new(pi_agent::PiAgentSource::new()), - Box::new(kimi::KimiSource::new()), - Box::new(droid::DroidSource::new()), - Box::new(openclaw::OpenClawSource::new()), - Box::new(qwen::QwenSource::new()), - Box::new(piebald::PiebaldSource::new()), - Box::new(cursor::CursorSource::new()), - ], - } + let mut providers: Vec> = vec![ + Box::new(claude_code::ClaudeCodeSource::new()), + Box::new(codex::CodexSource::new()), + Box::new(gemini::GeminiSource::new()), + Box::new(opencode::OpenCodeSource::new()), + Box::new(amp::AmpSource::new()), + Box::new(cline::ClineSource::new()), + Box::new(roo_code::RooCodeSource::new()), + Box::new(kilo_code::KiloCodeSource::new()), + Box::new(copilot::CopilotSource::new()), + Box::new(continue_dev::ContinueSource::new()), + Box::new(pi_agent::PiAgentSource::new()), + Box::new(kimi::KimiSource::new()), + Box::new(droid::DroidSource::new()), + Box::new(openclaw::OpenClawSource::new()), + Box::new(qwen::QwenSource::new()), + Box::new(piebald::PiebaldSource::new()), + Box::new(cursor::CursorSource::new()), + ]; + providers.extend( + custom::CustomSource::load_configured() + .into_iter() + .map(|source| Box::new(source) as Box), + ); + Self { providers } } pub fn available(&self) -> Vec<&dyn Source> { diff --git a/src/tui/app.rs b/src/tui/app.rs index a856920..6f32e92 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -3,7 +3,8 @@ use std::time::{Duration as StdDuration, Instant}; use crate::timestamp; use chrono::{Duration, NaiveDate, Utc}; -use crossterm::event::{KeyCode, KeyEvent}; +use crossterm::event::{KeyCode, KeyEvent, MouseEvent}; +use ratatui::layout::Rect; use crate::config::Config; use crate::render::{self, format_tokens_short}; @@ -13,6 +14,10 @@ use crate::{cache, cost, dedup, rollup}; use super::diff::{self, RowKey}; use super::event::Event; +use super::views::dashboard::{self, MouseAction}; +use super::views::settings::{self, MouseAction as SettingsMouseAction}; +use super::widgets::heatmap::{self, HeatmapDay}; +use super::widgets::spike_chart::{self, SpikeSeries}; /// Duration (in seconds) for the per-cell highlight fade animation. const HIGHLIGHT_DURATION_SECS: f64 = 1.5; @@ -20,6 +25,9 @@ const HIGHLIGHT_DURATION_SECS: f64 = 1.5; /// Duration (in seconds) for warnings to remain visible in the status bar. const WARNING_DISPLAY_SECS: f64 = 5.0; +/// Number of rows moved by one mouse-wheel event. +const MOUSE_SCROLL_ROWS: u16 = 3; + /// Maximum time the interactive UI waits for a cache write lock. /// /// The background watcher keeps the normal cache timeout. The TUI must stay @@ -28,6 +36,14 @@ const TUI_CACHE_BUSY_TIMEOUT: StdDuration = StdDuration::from_millis(100); // ── View scope ──────────────────────────────────────────────────────────── +/// Optional fullscreen visualization replacing the normal dashboard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FullscreenView { + None, + Heatmap, + SpikeChart, +} + /// Which time window the detail table shows. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Scope { @@ -38,6 +54,8 @@ pub enum Scope { } impl Scope { + pub const ALL: [Self; 4] = [Self::Today, Self::Week, Self::Month, Self::AllTime]; + #[must_use] pub fn label(self) -> &'static str { match self { @@ -58,11 +76,66 @@ impl Scope { Self::AllTime => NaiveDate::from_ymd_opt(2000, 1, 1).unwrap(), } } + + #[must_use] + pub(crate) const fn card_index(self) -> usize { + match self { + Self::Today => 0, + Self::Week => 1, + Self::Month => 2, + Self::AllTime => 3, + } + } + + #[must_use] + const fn from_card_toggle_key(key: char) -> Option { + match key { + 'T' => Some(Self::Today), + 'W' => Some(Self::Week), + 'M' => Some(Self::Month), + 'A' => Some(Self::AllTime), + _ => None, + } + } } // ── Summary card data ───────────────────────────────────────────────────── -/// Data for one summary card (Today / This Week / This Month). +/// Session-only visibility state for the four summary cards. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SummaryCardVisibility { + visible: [bool; 4], +} + +impl Default for SummaryCardVisibility { + fn default() -> Self { + Self { visible: [true; 4] } + } +} + +impl SummaryCardVisibility { + pub const fn toggle(&mut self, scope: Scope) { + let index = scope.card_index(); + self.visible[index] = !self.visible[index]; + } + + #[must_use] + pub const fn is_visible(self, scope: Scope) -> bool { + self.visible[scope.card_index()] + } + + #[must_use] + pub fn any_visible(self) -> bool { + self.visible.iter().any(|visible| *visible) + } + + #[must_use] + pub fn visible_count(self) -> usize { + self.visible.iter().filter(|visible| **visible).count() + } +} + +/// Data for one summary card (Today / This Week / This Month / All Time). #[derive(Debug, Clone)] pub struct CardData { pub label: &'static str, @@ -73,6 +146,13 @@ pub struct CardData { pub trend: i8, } +/// Interactive region currently under the pointer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum HoverTarget { + Card(Scope), + TableRow(RowKey), +} + impl CardData { #[must_use] pub fn cost_str(&self) -> String { @@ -153,6 +233,8 @@ pub struct App { pub show_history: bool, /// Summary cards: Today, This Week, This Month, All Time. pub cards: [CardData; 4], + /// Which summary cards are visible for this TUI session. + pub card_visibility: SummaryCardVisibility, /// Detail table rows for the selected scope. pub detail_models: Vec, /// Detail totals. @@ -179,12 +261,22 @@ pub struct App { /// it was last updated. Used for the green fade animation on /// individual table cells. pub highlight_map: HashMap, + /// Interactive region currently under the pointer, if any. + pub(crate) hovered: Option, /// Last warning message from the background watcher or data loading, /// with the instant it was received. Displayed in the status bar /// for a few seconds then cleared. pub last_warning: Option<(String, Instant)>, + /// Optional fullscreen visualization. + pub fullscreen: FullscreenView, + /// Daily contribution data loaded on demand. + pub heatmap_data: Vec, + /// Today's token activity loaded on demand. + pub spike_data: Option, /// Whether the settings overlay is shown. pub show_settings: bool, + /// Whether this session should exit when the settings overlay closes. + pub(crate) config_only: bool, /// Settings editor state. pub settings_state: SettingsState, /// Whether the UI state has changed and needs a redraw. @@ -260,6 +352,7 @@ impl App { trend: 0, }, ], + card_visibility: SummaryCardVisibility::default(), detail_models: Vec::new(), detail_total_cost: 0.0, detail_total_tokens: 0, @@ -273,8 +366,13 @@ impl App { applied_filter: String::new(), sort_order: SortOrder::CostDesc, highlight_map: HashMap::new(), + hovered: None, last_warning: None, + fullscreen: FullscreenView::None, + heatmap_data: Vec::new(), + spike_data: None, show_settings: false, + config_only: false, settings_state: SettingsState::new(config), dirty: true, config: config.clone(), @@ -313,13 +411,18 @@ impl App { } /// Handle an incoming event. Returns `true` if the UI needs a redraw. - pub fn handle_event(&mut self, event: &Event) -> bool { + pub fn handle_event(&mut self, event: &Event, terminal_area: Rect) -> bool { match event { Event::Key(key) => { let changed = self.handle_key(*key); self.dirty |= changed; changed } + Event::Mouse(mouse) => { + let changed = self.handle_mouse(*mouse, terminal_area); + self.dirty |= changed; + changed + } Event::Tick => { // Poll source files for changes (lightweight mtime checks), // re-parse any that changed, and update the cache. @@ -329,7 +432,11 @@ impl App { if let Err(e) = self.poll_sources() { self.set_warning(format!("Data refresh failed: {e}")); } - self.dirty |= self.reload_from_cache(); + let data_changed = self.reload_from_cache(); + if data_changed { + self.refresh_active_visual(); + } + self.dirty |= data_changed; // Expire old warnings if let Some((_, t)) = &self.last_warning { if t.elapsed().as_secs_f64() >= WARNING_DISPLAY_SECS { @@ -349,6 +456,7 @@ impl App { Event::DataChanged => { // The watcher already wrote to the cache — just re-read it. self.dirty |= self.reload_from_cache(); + self.refresh_active_visual(); self.dirty } Event::Warning(msg) => { @@ -364,6 +472,92 @@ impl App { } } + fn handle_mouse(&mut self, mouse: MouseEvent, terminal_area: Rect) -> bool { + if self.show_settings { + return self.handle_settings_mouse(mouse, terminal_area); + } + if self.show_help || self.filter_active { + return self.set_hover(None); + } + + let hover_changed = self.set_hover(dashboard::hover_target(terminal_area, self, mouse)); + let action_changed = + match dashboard::mouse_action(terminal_area, self.card_visibility, mouse) { + Some(MouseAction::SelectScope(scope)) => self.select_scope(scope), + Some(MouseAction::ScrollUp) => self.scroll_up(MOUSE_SCROLL_ROWS), + Some(MouseAction::ScrollDown) => self.scroll_down(MOUSE_SCROLL_ROWS), + None => false, + }; + hover_changed || action_changed + } + + fn handle_settings_mouse(&mut self, mouse: MouseEvent, terminal_area: Rect) -> bool { + // Keep an in-progress text edit isolated until Enter applies it or + // Escape cancels it, matching the keyboard editing behavior. + if self.settings_state.editing { + return false; + } + + match settings::mouse_action(terminal_area, self, mouse) { + Some(SettingsMouseAction::SelectField(field)) => { + if self.settings_state.selected == field { + false + } else { + self.settings_state.selected = field; + true + } + } + Some(SettingsMouseAction::ActivateField(field)) => { + self.settings_state.selected = field; + self.activate_current_setting() + } + Some(SettingsMouseAction::ScrollUp) => { + let next = self.settings_state.selected.saturating_sub(1); + if next == self.settings_state.selected { + false + } else { + self.settings_state.selected = next; + true + } + } + Some(SettingsMouseAction::ScrollDown) => { + let next = (self.settings_state.selected + 1) + .min(crate::tui::settings_state::SettingField::COUNT - 1); + if next == self.settings_state.selected { + false + } else { + self.settings_state.selected = next; + true + } + } + Some(SettingsMouseAction::ReviewSave) => { + self.settings_state.confirming_save = self.settings_state.unsaved; + true + } + Some(SettingsMouseAction::ConfirmSave) => self.save_settings(), + Some(SettingsMouseAction::CancelSave) => { + self.settings_state.confirming_save = false; + true + } + Some(SettingsMouseAction::Discard | SettingsMouseAction::Close) => { + self.show_settings = false; + if self.config_only { + self.should_quit = true; + } + true + } + None => false, + } + } + + fn set_hover(&mut self, target: Option) -> bool { + if self.hovered == target { + return false; + } + self.hovered = target; + true + } + /// Returns the current warning message if it's still fresh (< 5 seconds old). #[must_use] pub fn active_warning(&self) -> Option<&str> { @@ -399,6 +593,49 @@ impl App { return self.handle_filter_key(key); } + if self.fullscreen != FullscreenView::None { + return match key.code { + KeyCode::Esc => { + self.fullscreen = FullscreenView::None; + true + } + KeyCode::Char('q') => { + self.should_quit = true; + false + } + KeyCode::Char('?') => { + self.show_help = true; + true + } + KeyCode::Char('c') => { + self.fullscreen = if self.fullscreen == FullscreenView::Heatmap { + FullscreenView::None + } else { + self.recompute_heatmap(); + FullscreenView::Heatmap + }; + true + } + KeyCode::Char('v') => { + self.fullscreen = if self.fullscreen == FullscreenView::SpikeChart { + FullscreenView::None + } else { + self.recompute_spike_chart(); + FullscreenView::SpikeChart + }; + true + } + _ => false, + }; + } + + if let KeyCode::Char(c) = key.code { + if let Some(scope) = Scope::from_card_toggle_key(c) { + self.card_visibility.toggle(scope); + return true; + } + } + match key.code { KeyCode::Char('q') | KeyCode::Esc => { if self.applied_filter.is_empty() { @@ -425,26 +662,10 @@ impl App { self.filter_text = self.applied_filter.clone(); true } - KeyCode::Char('t') => { - self.scope = Scope::Today; - self.reset_view_state(); - true - } - KeyCode::Char('w') => { - self.scope = Scope::Week; - self.reset_view_state(); - true - } - KeyCode::Char('m') => { - self.scope = Scope::Month; - self.reset_view_state(); - true - } - KeyCode::Char('a') => { - self.scope = Scope::AllTime; - self.reset_view_state(); - true - } + KeyCode::Char('t') => self.select_scope(Scope::Today), + KeyCode::Char('w') => self.select_scope(Scope::Week), + KeyCode::Char('m') => self.select_scope(Scope::Month), + KeyCode::Char('a') => self.select_scope(Scope::AllTime), KeyCode::Char('s') => { self.sort_order = self.sort_order.next(); self.reset_view_state(); @@ -465,16 +686,18 @@ impl App { self.recompute_detail(); true } - KeyCode::Char('j') | KeyCode::Down => { - let max = - u16::try_from(self.rendered_row_count().saturating_sub(1)).unwrap_or(u16::MAX); - self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); + KeyCode::Char('c') => { + self.recompute_heatmap(); + self.fullscreen = FullscreenView::Heatmap; true } - KeyCode::Char('k') | KeyCode::Up => { - self.scroll_offset = self.scroll_offset.saturating_sub(1); + KeyCode::Char('v') => { + self.recompute_spike_chart(); + self.fullscreen = FullscreenView::SpikeChart; true } + KeyCode::Char('j') | KeyCode::Down => self.scroll_down(1), + KeyCode::Char('k') | KeyCode::Up => self.scroll_up(1), KeyCode::Left => { let new_scope = match self.scope { Scope::Today | Scope::Week => Scope::Today, @@ -507,6 +730,33 @@ impl App { } } + fn select_scope(&mut self, scope: Scope) -> bool { + self.scope = scope; + self.reset_view_state(); + true + } + + fn scroll_down(&mut self, rows: u16) -> bool { + let max = u16::try_from(self.rendered_row_count().saturating_sub(1)).unwrap_or(u16::MAX); + let next = self.scroll_offset.saturating_add(rows).min(max); + if next == self.scroll_offset { + false + } else { + self.scroll_offset = next; + true + } + } + + fn scroll_up(&mut self, rows: u16) -> bool { + let next = self.scroll_offset.saturating_sub(rows); + if next == self.scroll_offset { + false + } else { + self.scroll_offset = next; + true + } + } + fn handle_filter_key(&mut self, key: KeyEvent) -> bool { match key.code { KeyCode::Enter => { @@ -534,6 +784,17 @@ impl App { #[allow(clippy::too_many_lines)] fn handle_settings_key(&mut self, key: KeyEvent) -> bool { + if self.settings_state.confirming_save { + return match key.code { + KeyCode::Char('y' | 'Y') | KeyCode::Enter => self.save_settings(), + KeyCode::Char('n' | 'N') | KeyCode::Esc => { + self.settings_state.confirming_save = false; + true + } + _ => false, + }; + } + let state = &mut self.settings_state; // If editing a text/numeric field @@ -541,8 +802,11 @@ impl App { match key.code { KeyCode::Enter => { let field = state.current_field(); - if field.apply_value(&mut state.draft, &state.edit_buffer) { - state.unsaved = true; + match field.apply_value(&mut state.draft, &state.edit_buffer) { + Ok(()) => state.unsaved = true, + Err(message) => { + state.flash_message = Some((message.to_string(), Instant::now())); + } } state.editing = false; state.edit_buffer.clear(); @@ -553,7 +817,9 @@ impl App { state.edit_buffer.clear(); return true; } - KeyCode::Char(c) if c.is_ascii_digit() || c == '.' => { + KeyCode::Char(c) + if state.current_field().is_text() || c.is_ascii_digit() || c == '.' => + { state.edit_buffer.push(c); return true; } @@ -570,6 +836,9 @@ impl App { KeyCode::Esc | KeyCode::Char('S') => { // Close settings, discard unsaved changes self.show_settings = false; + if self.config_only { + self.should_quit = true; + } true } KeyCode::Char('j') | KeyCode::Down => { @@ -580,23 +849,7 @@ impl App { state.selected = state.selected.saturating_sub(1); true } - KeyCode::Enter | KeyCode::Char(' ') => { - let field = state.current_field(); - if field.is_bool() { - field.toggle_bool(&mut state.draft); - state.unsaved = true; - true - } else if field.is_enum() { - field.cycle_enum(&mut state.draft); - state.unsaved = true; - true - } else { - // Enter edit mode for numeric fields - state.editing = true; - state.edit_buffer = field.edit_value(&state.draft); - true - } - } + KeyCode::Enter | KeyCode::Char(' ') => self.activate_current_setting(), KeyCode::Left => { let field = state.current_field(); if field.is_enum() { @@ -619,33 +872,52 @@ impl App { } } KeyCode::Char('W') => { - // Save to disk - let old_metric = self.config.sparkline_metric; - match self.settings_state.draft.save() { - Ok(()) => { - self.config = self.settings_state.draft.clone(); - self.no_cost = self.settings_state.draft.no_cost; - self.settings_state.unsaved = false; - self.settings_state.flash_message = - Some(("Saved!".to_string(), Instant::now())); - // Recompute all-time base if sparkline metric changed - if self.config.sparkline_metric != old_metric { - if let Err(e) = self.compute_all_time_base() { - self.set_warning(format!("History refresh failed: {e}")); - } - } - } - Err(e) => { - self.last_warning = - Some((format!("Failed to save config: {e}"), Instant::now())); - } - } + self.settings_state.confirming_save = self.settings_state.unsaved; true } _ => false, } } + fn activate_current_setting(&mut self) -> bool { + let field = self.settings_state.current_field(); + if field.is_bool() { + field.toggle_bool(&mut self.settings_state.draft); + self.settings_state.unsaved = true; + } else if field.is_enum() { + field.cycle_enum(&mut self.settings_state.draft); + self.settings_state.unsaved = true; + } else { + self.settings_state.editing = true; + self.settings_state.edit_buffer = field.edit_value(&self.settings_state.draft); + } + true + } + + fn save_settings(&mut self) -> bool { + let old_metric = self.config.sparkline_metric; + match self.settings_state.draft.save() { + Ok(()) => { + self.config = self.settings_state.draft.clone(); + self.no_cost = self.settings_state.draft.no_cost; + self.settings_state.unsaved = false; + self.settings_state.confirming_save = false; + self.settings_state.flash_message = Some(("Saved!".to_string(), Instant::now())); + if self.config.sparkline_metric != old_metric { + if let Err(e) = self.compute_all_time_base() { + self.set_warning(format!("History refresh failed: {e}")); + } + } + } + Err(error) => { + self.settings_state.confirming_save = false; + self.settings_state.flash_message = + Some((format!("Failed to save config: {error}"), Instant::now())); + } + } + true + } + /// Load all records older than the in-memory window, apply pricing, /// and compute base totals and weekly sparkline for the All Time card. /// Called once at startup. @@ -1014,6 +1286,44 @@ impl App { self.history_summaries.clear(); } } + + fn refresh_active_visual(&mut self) { + match self.fullscreen { + FullscreenView::None => {} + FullscreenView::Heatmap => self.recompute_heatmap(), + FullscreenView::SpikeChart => self.recompute_spike_chart(), + } + self.dirty |= self.fullscreen != FullscreenView::None; + } + + fn recompute_heatmap(&mut self) { + let since = Utc::now().date_naive() - Duration::days(364); + match cache::Cache::open() + .and_then(|cache| cache.load_entries_filtered(Some(since), None, &[])) + { + Ok(records) => { + self.heatmap_data = heatmap::build_heatmap_data(&records); + } + Err(error) => { + self.heatmap_data.clear(); + self.last_warning = Some(( + format!("Contribution history unavailable: {error}"), + Instant::now(), + )); + } + } + } + + fn recompute_spike_chart(&mut self) { + let now = Utc::now(); + let start = spike_chart::start_of_day(now); + self.spike_data = Some(spike_chart::build_spike_data( + &self.cached_records, + start, + now, + 5 * 60, + )); + } } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -1114,4 +1424,48 @@ mod tests { let history = vec![summary(2)]; assert_eq!(table_row_count(&details, &history, false), 5); } + + #[test] + fn summary_cards_start_visible_and_toggle_independently() { + let mut visibility = SummaryCardVisibility::default(); + assert_eq!(visibility.visible_count(), 4); + assert!(Scope::ALL + .into_iter() + .all(|scope| visibility.is_visible(scope))); + + visibility.toggle(Scope::Week); + visibility.toggle(Scope::AllTime); + + assert!(visibility.is_visible(Scope::Today)); + assert!(!visibility.is_visible(Scope::Week)); + assert!(visibility.is_visible(Scope::Month)); + assert!(!visibility.is_visible(Scope::AllTime)); + assert_eq!(visibility.visible_count(), 2); + + visibility.toggle(Scope::Week); + assert!(visibility.is_visible(Scope::Week)); + assert_eq!(visibility.visible_count(), 3); + } + + #[test] + fn all_summary_cards_can_be_hidden() { + let mut visibility = SummaryCardVisibility::default(); + for scope in Scope::ALL { + visibility.toggle(scope); + } + + assert!(!visibility.any_visible()); + assert_eq!(visibility.visible_count(), 0); + } + + #[test] + fn only_uppercase_scope_keys_toggle_cards() { + assert_eq!(Scope::from_card_toggle_key('T'), Some(Scope::Today)); + assert_eq!(Scope::from_card_toggle_key('W'), Some(Scope::Week)); + assert_eq!(Scope::from_card_toggle_key('M'), Some(Scope::Month)); + assert_eq!(Scope::from_card_toggle_key('A'), Some(Scope::AllTime)); + assert_eq!(Scope::from_card_toggle_key('t'), None); + assert_eq!(Scope::from_card_toggle_key('w'), None); + assert_eq!(Scope::from_card_toggle_key('x'), None); + } } diff --git a/src/tui/event.rs b/src/tui/event.rs index e3f33d9..e6df361 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -1,6 +1,8 @@ use std::time::Duration; -use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind}; +use crossterm::event::{ + Event as CrosstermEvent, EventStream, KeyEventKind, MouseButton, MouseEventKind, +}; use futures_lite::StreamExt; use tokio::sync::mpsc; @@ -9,6 +11,8 @@ use tokio::sync::mpsc; pub enum Event { /// A key was pressed. Key(crossterm::event::KeyEvent), + /// A mouse button was pressed, the wheel was scrolled, or the pointer moved. + Mouse(crossterm::event::MouseEvent), /// Terminal was resized (values used by ratatui's `frame.area()` implicitly). #[allow(dead_code)] Resize(u16, u16), @@ -75,13 +79,7 @@ impl EventHandler { // Crossterm terminal events (key presses, resize, etc.) maybe_event = crossterm_events.next() => { match maybe_event { - Some(Ok(evt)) => match evt { - CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => { - Some(Event::Key(key)) - } - CrosstermEvent::Resize(w, h) => Some(Event::Resize(w, h)), - _ => None, - }, + Some(Ok(evt)) => map_crossterm_event(&evt), // Stream ended or error — stop the loop Some(Err(_)) | None => break, } @@ -116,3 +114,73 @@ impl EventHandler { self.rx.try_recv() } } + +fn map_crossterm_event(event: &CrosstermEvent) -> Option { + match event { + CrosstermEvent::Key(key) if key.kind == KeyEventKind::Press => Some(Event::Key(*key)), + CrosstermEvent::Mouse(mouse) + if matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left) + | MouseEventKind::ScrollUp + | MouseEventKind::ScrollDown + | MouseEventKind::Moved + ) => + { + Some(Event::Mouse(*mouse)) + } + CrosstermEvent::Resize(width, height) => Some(Event::Resize(*width, *height)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, + }; + + use super::{map_crossterm_event, CrosstermEvent, Event}; + + #[test] + fn routes_mouse_events() { + let mouse = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 12, + row: 4, + modifiers: KeyModifiers::NONE, + }; + + let mapped = map_crossterm_event(&CrosstermEvent::Mouse(mouse)); + + assert!(matches!(mapped, Some(Event::Mouse(event)) if event == mouse)); + } + + #[test] + fn ignores_key_release_events() { + let key = KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Release, + state: KeyEventState::NONE, + }; + + assert!(map_crossterm_event(&CrosstermEvent::Key(key)).is_none()); + } + + #[test] + fn routes_mouse_motion() { + let mouse = MouseEvent { + kind: MouseEventKind::Moved, + column: 12, + row: 4, + modifiers: KeyModifiers::NONE, + }; + + assert!(matches!( + map_crossterm_event(&CrosstermEvent::Mouse(mouse)), + Some(Event::Mouse(event)) if event == mouse + )); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 99a9362..988fb86 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -11,6 +11,8 @@ mod widgets; use std::time::Duration; +use ratatui::layout::Rect; + use crate::config::Config; use app::{App, Scope}; use event::{Event, EventHandler}; @@ -53,7 +55,17 @@ pub fn run( .enable_all() .build()?; - runtime.block_on(async { run_async(config, scope, tick_secs, offline).await }) + runtime.block_on(async { run_async(config, scope, tick_secs, offline, false).await }) +} + +/// Run the keyboard-driven configuration editor without requiring a dashboard session. +pub fn run_config(config: &Config, offline: bool) -> anyhow::Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + runtime + .block_on(async { run_async(config, Scope::Today, DEFAULT_TICK_SECS, offline, true).await }) } async fn run_async( @@ -61,9 +73,13 @@ async fn run_async( scope: Scope, tick_secs: u64, offline: bool, + config_only: bool, ) -> anyhow::Result<()> { let mut terminal = terminal::init()?; + let mut terminal_area = Rect::from(terminal.size()?); let mut app = App::new(config, scope, offline); + app.config_only = config_only; + app.show_settings = config_only; let mut events = EventHandler::new( Duration::from_secs(tick_secs), @@ -87,7 +103,10 @@ async fn run_async( match &event { Event::Render => {} // render ticks don't mark state dirty other => { - app.handle_event(other); + if let Event::Resize(width, height) = other { + terminal_area = Rect::new(0, 0, *width, *height); + } + app.handle_event(other, terminal_area); } } diff --git a/src/tui/settings_state.rs b/src/tui/settings_state.rs index 0023602..5dc70af 100644 --- a/src/tui/settings_state.rs +++ b/src/tui/settings_state.rs @@ -8,8 +8,13 @@ use crate::config::Config; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingField { TickInterval, - NoCost, DefaultCommand, + DefaultFormat, + Breakdown, + NoCost, + Offline, + Refresh, + Reparse, SortOrder, ShowSparklines, SparklineMetric, @@ -19,10 +24,15 @@ pub(crate) enum SettingField { BudgetDaily, BudgetWeekly, BudgetMonthly, + Providers, + ColDate, + ColModel, ColApiProvider, ColClient, ColInput, ColOutput, + ColCacheWrite, + ColCacheRead, ColRequests, ColTotalTokens, ColCost, @@ -30,13 +40,18 @@ pub(crate) enum SettingField { impl SettingField { /// Total number of settings fields. - pub const COUNT: usize = 19; + pub const COUNT: usize = 29; /// All fields in display order. pub const ALL: [Self; Self::COUNT] = [ Self::TickInterval, - Self::NoCost, Self::DefaultCommand, + Self::DefaultFormat, + Self::Breakdown, + Self::NoCost, + Self::Offline, + Self::Refresh, + Self::Reparse, Self::SortOrder, Self::ShowSparklines, Self::SparklineMetric, @@ -46,10 +61,15 @@ impl SettingField { Self::BudgetDaily, Self::BudgetWeekly, Self::BudgetMonthly, + Self::Providers, + Self::ColDate, + Self::ColModel, Self::ColApiProvider, Self::ColClient, Self::ColInput, Self::ColOutput, + Self::ColCacheWrite, + Self::ColCacheRead, Self::ColRequests, Self::ColTotalTokens, Self::ColCost, @@ -60,8 +80,13 @@ impl SettingField { pub fn label(self) -> &'static str { match self { Self::TickInterval => "Tick Interval (s) *", - Self::NoCost => "Disable Costs", Self::DefaultCommand => "Default Command", + Self::DefaultFormat => "Default Format", + Self::Breakdown => "Model Breakdown", + Self::NoCost => "Disable Costs", + Self::Offline => "Offline Pricing", + Self::Refresh => "Always Refresh", + Self::Reparse => "Always Reparse", Self::SortOrder => "Sort Order", Self::ShowSparklines => "Show Sparklines", Self::SparklineMetric => "Sparkline Metric", @@ -71,10 +96,15 @@ impl SettingField { Self::BudgetDaily => "Daily Budget ($)", Self::BudgetWeekly => "Weekly Budget ($)", Self::BudgetMonthly => "Monthly Budget ($)", + Self::Providers => "Providers (comma-separated)", + Self::ColDate => "Date", + Self::ColModel => "Model", Self::ColApiProvider => "API Provider", Self::ColClient => "Client", Self::ColInput => "Input Tokens", Self::ColOutput => "Output Tokens", + Self::ColCacheWrite => "Cache Write", + Self::ColCacheRead => "Cache Read", Self::ColRequests => "Requests", Self::ColTotalTokens => "Total Tokens", Self::ColCost => "Cost", @@ -86,9 +116,11 @@ impl SettingField { pub fn section_header(self) -> Option<&'static str> { match self { Self::TickInterval => Some("General"), + Self::DefaultCommand => Some("Defaults"), Self::ShowSparklines => Some("Sparklines"), Self::BudgetDaily => Some("Budget Limits"), - Self::ColApiProvider => Some("Columns"), + Self::Providers => Some("Sources"), + Self::ColDate => Some("Columns"), _ => None, } } @@ -98,12 +130,20 @@ impl SettingField { pub fn is_bool(self) -> bool { matches!( self, - Self::NoCost + Self::Breakdown + | Self::NoCost + | Self::Offline + | Self::Refresh + | Self::Reparse | Self::ShowSparklines + | Self::ColDate + | Self::ColModel | Self::ColApiProvider | Self::ColClient | Self::ColInput | Self::ColOutput + | Self::ColCacheWrite + | Self::ColCacheRead | Self::ColRequests | Self::ColTotalTokens | Self::ColCost @@ -115,10 +155,16 @@ impl SettingField { pub fn is_enum(self) -> bool { matches!( self, - Self::DefaultCommand | Self::SortOrder | Self::SparklineMetric + Self::DefaultCommand | Self::DefaultFormat | Self::SortOrder | Self::SparklineMetric ) } + /// Whether this field accepts free-form text input. + #[must_use] + pub fn is_text(self) -> bool { + matches!(self, Self::Providers) + } + /// Get the current value as a display string from a config. #[must_use] pub fn display_value(self, config: &Config) -> String { @@ -131,8 +177,13 @@ impl SettingField { v.to_string() } } - Self::NoCost => if config.no_cost { "Yes" } else { "No" }.to_string(), Self::DefaultCommand => config.default_command.to_string(), + Self::DefaultFormat => config.default_format.clone(), + Self::Breakdown => bool_display(config.breakdown), + Self::NoCost => bool_display(config.no_cost), + Self::Offline => bool_display(config.offline), + Self::Refresh => bool_display(config.refresh), + Self::Reparse => bool_display(config.reparse), Self::SortOrder => config.sort_order.to_string(), Self::ShowSparklines => if config.show_sparklines { "Yes" } else { "No" }.to_string(), Self::SparklineMetric => config.sparkline_metric.to_string(), @@ -151,10 +202,21 @@ impl SettingField { .budget .monthly .map_or("--".to_string(), |v| format!("{v:.2}")), + Self::Providers => { + if config.providers.is_empty() { + "(all)".to_string() + } else { + config.providers.join(", ") + } + } + Self::ColDate => bool_display(config.columns.date), + Self::ColModel => bool_display(config.columns.model), Self::ColApiProvider => bool_display(config.columns.api_provider), Self::ColClient => bool_display(config.columns.client), Self::ColInput => bool_display(config.columns.input), Self::ColOutput => bool_display(config.columns.output), + Self::ColCacheWrite => bool_display(config.columns.cache_write), + Self::ColCacheRead => bool_display(config.columns.cache_read), Self::ColRequests => bool_display(config.columns.requests), Self::ColTotalTokens => bool_display(config.columns.total_tokens), Self::ColCost => bool_display(config.columns.cost), @@ -164,12 +226,20 @@ impl SettingField { /// Toggle a boolean field on the given config. No-op for non-bool fields. pub fn toggle_bool(self, config: &mut Config) { match self { + Self::Breakdown => config.breakdown = !config.breakdown, Self::NoCost => config.no_cost = !config.no_cost, + Self::Offline => config.offline = !config.offline, + Self::Refresh => config.refresh = !config.refresh, + Self::Reparse => config.reparse = !config.reparse, Self::ShowSparklines => config.show_sparklines = !config.show_sparklines, + Self::ColDate => config.columns.date = !config.columns.date, + Self::ColModel => config.columns.model = !config.columns.model, Self::ColApiProvider => config.columns.api_provider = !config.columns.api_provider, Self::ColClient => config.columns.client = !config.columns.client, Self::ColInput => config.columns.input = !config.columns.input, Self::ColOutput => config.columns.output = !config.columns.output, + Self::ColCacheWrite => config.columns.cache_write = !config.columns.cache_write, + Self::ColCacheRead => config.columns.cache_read = !config.columns.cache_read, Self::ColRequests => config.columns.requests = !config.columns.requests, Self::ColTotalTokens => config.columns.total_tokens = !config.columns.total_tokens, Self::ColCost => config.columns.cost = !config.columns.cost, @@ -183,6 +253,13 @@ impl SettingField { Self::DefaultCommand => { config.default_command = config.default_command.next(); } + Self::DefaultFormat => { + config.default_format = if config.default_format == "json" { + "table".to_string() + } else { + "json".to_string() + }; + } Self::SortOrder => { config.sort_order = config.sort_order.next(); } @@ -194,45 +271,62 @@ impl SettingField { } /// Apply a string value from the edit buffer to the config. - /// Returns `true` if the value was valid and applied. - pub fn apply_value(self, config: &mut Config, value: &str) -> bool { + /// Returns an error when the value is outside the accepted range. + pub fn apply_value(self, config: &mut Config, value: &str) -> Result<(), &'static str> { match self { Self::TickInterval => { - if let Ok(v) = value.parse::() { - config.tick_interval = v.min(300); - true - } else { - false + let v = value + .parse::() + .map_err(|_| "Enter a whole number from 0 to 300")?; + if v > 300 { + return Err("Tick interval must be between 0 and 300 seconds"); } + config.tick_interval = v; + Ok(()) } Self::TodayBucketMins => { - if let Ok(v) = value.parse::() { - config.today_bucket_mins = v.clamp(1, 60); - true - } else { - false + let v = value + .parse::() + .map_err(|_| "Enter a whole number from 1 to 60")?; + if !(1..=60).contains(&v) { + return Err("Today bucket must be between 1 and 60 minutes"); } + config.today_bucket_mins = v; + Ok(()) } Self::WeekBucketHours => { - if let Ok(v) = value.parse::() { - config.week_bucket_hours = v.clamp(1, 24); - true - } else { - false + let v = value + .parse::() + .map_err(|_| "Enter a whole number from 1 to 24")?; + if !(1..=24).contains(&v) { + return Err("Week bucket must be between 1 and 24 hours"); } + config.week_bucket_hours = v; + Ok(()) } Self::MonthBucketDays => { - if let Ok(v) = value.parse::() { - config.month_bucket_days = v.clamp(1, 7); - true - } else { - false + let v = value + .parse::() + .map_err(|_| "Enter a whole number from 1 to 7")?; + if !(1..=7).contains(&v) { + return Err("Month bucket must be between 1 and 7 days"); } + config.month_bucket_days = v; + Ok(()) } Self::BudgetDaily => apply_budget_value(&mut config.budget.daily, value), Self::BudgetWeekly => apply_budget_value(&mut config.budget.weekly, value), Self::BudgetMonthly => apply_budget_value(&mut config.budget.monthly, value), - _ => false, + Self::Providers => { + config.providers = value + .split(',') + .map(str::trim) + .filter(|provider| !provider.is_empty()) + .map(str::to_string) + .collect(); + Ok(()) + } + _ => Err("This setting is not text-editable"), } } @@ -256,6 +350,7 @@ impl SettingField { .budget .monthly .map_or(String::new(), |v| format!("{v:.2}")), + Self::Providers => config.providers.join(", "), _ => String::new(), } } @@ -265,20 +360,19 @@ pub(crate) fn bool_display(v: bool) -> String { if v { "Yes" } else { "No" }.to_string() } -fn apply_budget_value(target: &mut Option, value: &str) -> bool { +fn apply_budget_value(target: &mut Option, value: &str) -> Result<(), &'static str> { if value.is_empty() { *target = None; - return true; + return Ok(()); } - if let Ok(v) = value.parse::() { - if v > 0.0 && v.is_finite() { - *target = Some(v); - } else { - *target = None; - } - true + let v = value + .parse::() + .map_err(|_| "Enter a positive number or leave blank")?; + if v > 0.0 && v.is_finite() { + *target = Some(v); + Ok(()) } else { - false + Err("Budget must be a positive finite number") } } @@ -292,6 +386,8 @@ pub(crate) struct SettingsState { pub selected: usize, /// Whether we're currently editing a text/numeric field. pub editing: bool, + /// Whether the user is reviewing a save before it is written. + pub confirming_save: bool, /// Text buffer for the field being edited. pub edit_buffer: String, /// Brief confirmation message (e.g. "Saved!"), with the instant it was set. @@ -305,6 +401,7 @@ impl SettingsState { unsaved: false, selected: 0, editing: false, + confirming_save: false, edit_buffer: String::new(), flash_message: None, } @@ -355,4 +452,30 @@ mod tests { assert!(!config.columns.total_tokens); assert!(!config.columns.cost); } + + #[test] + fn invalid_numeric_values_are_rejected_without_clamping() { + let mut config = Config::default(); + assert!(SettingField::TickInterval + .apply_value(&mut config, "301") + .is_err()); + assert_eq!(config.tick_interval, 0); + assert!(SettingField::TodayBucketMins + .apply_value(&mut config, "0") + .is_err()); + assert_eq!(config.today_bucket_mins, 10); + assert!(SettingField::BudgetDaily + .apply_value(&mut config, "-1") + .is_err()); + assert!(config.budget.daily.is_none()); + } + + #[test] + fn providers_are_editable_as_a_comma_separated_list() { + let mut config = Config::default(); + SettingField::Providers + .apply_value(&mut config, "alpha, beta") + .expect("provider list should apply"); + assert_eq!(config.providers, ["alpha", "beta"]); + } } diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 0f23740..837d894 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -8,6 +8,9 @@ pub const BG: Color = Color::Rgb(15, 17, 22); /// Slightly lighter surface for panels / cards. pub const SURFACE: Color = Color::Rgb(22, 25, 33); +/// Slightly lighter surface used for pointer hover feedback. +pub const SURFACE_HOVER: Color = Color::Rgb(30, 34, 45); + /// Borders, separators. pub const BORDER: Color = Color::Rgb(48, 54, 68); @@ -196,6 +199,21 @@ pub fn highlight_cell(intensity: f64, normal_fg: Color) -> Style { style } +/// Stable colour for a model's API provider in usage visualizations. +#[must_use] +pub fn provider_color(provider: &str) -> Color { + match provider { + "Anthropic" => Color::Rgb(255, 140, 80), + "OpenAI" | "AWS Bedrock" | "Azure" => GREEN, + "Google" | "Vertex AI" => Color::Rgb(85, 150, 255), + "DeepSeek" => Color::Rgb(180, 120, 255), + "Mistral" => YELLOW, + "Meta" => CYAN, + "Alibaba" => RED, + _ => DIM, + } +} + /// Linearly interpolate between two RGB colours. fn lerp_color(from: Color, to: Color, t: f64) -> Color { let t = t.clamp(0.0, 1.0); diff --git a/src/tui/views/dashboard.rs b/src/tui/views/dashboard.rs index 4b16396..23c2825 100644 --- a/src/tui/views/dashboard.rs +++ b/src/tui/views/dashboard.rs @@ -1,22 +1,29 @@ -use ratatui::layout::{Constraint, Layout}; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::widgets::Block; use ratatui::Frame; -use crate::tui::app::App; +use crate::tui::app::{App, FullscreenView, HoverTarget, SummaryCardVisibility}; use crate::tui::theme; use crate::tui::views::{help, settings}; -use crate::tui::widgets::{header, status_bar, summary_cards, usage_table}; +use crate::tui::widgets::{header, heatmap, spike_chart, status_bar, summary_cards, usage_table}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MouseAction { + SelectScope(crate::tui::app::Scope), + ScrollUp, + ScrollDown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DashboardLayout { + header: Rect, + summary_cards: Option, + pub usage_table: Rect, + status_bar: Rect, +} /// Render the complete dashboard view. -/// -/// Layout: -/// ```text -/// ┌────────────── header (1 line) ──────────────┐ -/// ├──────────── summary cards (7 lines) ────────┤ -/// ├────────── usage table (flexible) ───────────┤ -/// ├────────────── status bar (1 line) ──────────┤ -/// └─────────────────────────────────────────────┘ -/// ``` pub fn render(frame: &mut Frame, app: &App) { let area = frame.area(); @@ -24,46 +31,46 @@ pub fn render(frame: &mut Frame, app: &App) { let bg = Block::default().style(theme::text()); frame.render_widget(bg, area); - // Determine card height based on terminal height - let card_height = if area.height >= 30 { - 7 - } else if area.height >= 20 { - 5 - } else { - 0 // Skip cards on very small terminals - }; - - let mut constraints = vec![ - Constraint::Length(1), // header - ]; + if app.fullscreen != FullscreenView::None { + let layout = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(area); + header::render(frame, layout[0], app); + match app.fullscreen { + FullscreenView::Heatmap => heatmap::render(frame, layout[1], &app.heatmap_data), + FullscreenView::SpikeChart => { + spike_chart::render(frame, layout[1], app.spike_data.as_ref()); + } + FullscreenView::None => {} + } + status_bar::render(frame, layout[2], app); - if card_height > 0 { - constraints.push(Constraint::Length(card_height)); // summary cards + if app.show_help { + help::render(frame); + } + if app.show_settings { + settings::render(frame, app); + } + return; } - - constraints.push(Constraint::Min(5)); // usage table - constraints.push(Constraint::Length(1)); // status bar - - let layout = Layout::vertical(constraints).split(area); - - let mut idx = 0; + let layout = dashboard_layout(area, app.card_visibility.any_visible()); // Header - header::render(frame, layout[idx], app); - idx += 1; + header::render(frame, layout.header, app); - // Summary cards (if space) - if card_height > 0 { - summary_cards::render(frame, layout[idx], app); - idx += 1; + // Summary cards (if space and at least one card is visible) + if let Some(cards_area) = layout.summary_cards { + summary_cards::render(frame, cards_area, app); } // Usage table - usage_table::render(frame, layout[idx], app); - idx += 1; + usage_table::render(frame, layout.usage_table, app); // Status bar - status_bar::render(frame, layout[idx], app); + status_bar::render(frame, layout.status_bar, app); // Overlays (rendered on top of everything) if app.show_help { @@ -73,3 +80,219 @@ pub fn render(frame: &mut Frame, app: &App) { settings::render(frame, app); } } + +#[must_use] +const fn summary_card_height(terminal_height: u16, has_visible_cards: bool) -> u16 { + if !has_visible_cards { + 0 + } else if terminal_height >= 30 { + 7 + } else if terminal_height >= 20 { + 5 + } else { + 0 + } +} + +/// Calculate the dashboard regions used by both rendering and mouse hit-testing. +#[must_use] +pub(crate) fn dashboard_layout(area: Rect, cards_visible: bool) -> DashboardLayout { + let card_height = summary_card_height(area.height, cards_visible); + let mut constraints = vec![Constraint::Length(1)]; + if card_height > 0 { + constraints.push(Constraint::Length(card_height)); + } + constraints.push(Constraint::Min(5)); + constraints.push(Constraint::Length(1)); + + let areas = Layout::vertical(constraints).split(area); + let mut index = 0; + let header = areas[index]; + index += 1; + let summary_cards = if card_height > 0 { + let cards = areas[index]; + index += 1; + Some(cards) + } else { + None + }; + + DashboardLayout { + header, + summary_cards, + usage_table: areas[index], + status_bar: areas[index + 1], + } +} + +/// Translate a raw mouse event into a dashboard action. +#[must_use] +pub(crate) fn mouse_action( + area: Rect, + visibility: SummaryCardVisibility, + event: MouseEvent, +) -> Option { + let layout = dashboard_layout(area, visibility.any_visible()); + + match event.kind { + MouseEventKind::Down(MouseButton::Left) => layout + .summary_cards + .and_then(|cards| summary_cards::scope_at(cards, visibility, event.column, event.row)) + .map(MouseAction::SelectScope), + MouseEventKind::ScrollUp if contains(layout.usage_table, event.column, event.row) => { + Some(MouseAction::ScrollUp) + } + MouseEventKind::ScrollDown if contains(layout.usage_table, event.column, event.row) => { + Some(MouseAction::ScrollDown) + } + _ => None, + } +} + +/// Resolve pointer movement to a rendered interactive region. +#[must_use] +pub(crate) fn hover_target(area: Rect, app: &App, event: MouseEvent) -> Option { + let cards_visible = app.card_visibility.any_visible(); + let layout = dashboard_layout(area, cards_visible); + + layout + .summary_cards + .and_then(|cards| { + summary_cards::scope_at(cards, app.card_visibility, event.column, event.row) + }) + .map(HoverTarget::Card) + .or_else(|| { + usage_table::row_at(layout.usage_table, app, event.column, event.row) + .map(HoverTarget::TableRow) + }) +} + +fn contains(area: Rect, column: u16, row: u16) -> bool { + column >= area.x + && column < area.x.saturating_add(area.width) + && row >= area.y + && row < area.y.saturating_add(area.height) +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use ratatui::layout::Rect; + + use super::{dashboard_layout, mouse_action, summary_card_height, MouseAction}; + use crate::tui::app::{Scope, SummaryCardVisibility}; + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn hidden_cards_reclaim_the_layout_space() { + assert_eq!(summary_card_height(40, false), 0); + assert_eq!(summary_card_height(25, false), 0); + } + + #[test] + fn visible_cards_keep_responsive_height() { + assert_eq!(summary_card_height(30, true), 7); + assert_eq!(summary_card_height(20, true), 5); + assert_eq!(summary_card_height(19, true), 0); + } + + #[test] + fn dashboard_layout_uses_responsive_card_heights() { + let visible = SummaryCardVisibility::default(); + let large = dashboard_layout(Rect::new(0, 0, 80, 30), visible.any_visible()); + assert_eq!(large.summary_cards, Some(Rect::new(0, 1, 80, 7))); + assert_eq!(large.usage_table, Rect::new(0, 8, 80, 21)); + + let mut hidden_visibility = SummaryCardVisibility::default(); + for scope in Scope::ALL { + hidden_visibility.toggle(scope); + } + let hidden = dashboard_layout(Rect::new(0, 0, 80, 30), hidden_visibility.any_visible()); + assert_eq!(hidden.summary_cards, None); + assert_eq!(hidden.usage_table, Rect::new(0, 1, 80, 28)); + } + + #[test] + fn left_click_on_card_selects_its_scope() { + let area = Rect::new(0, 0, 80, 30); + + assert_eq!( + mouse_action( + area, + SummaryCardVisibility::default(), + mouse(MouseEventKind::Down(MouseButton::Left), 45, 2), + ), + Some(MouseAction::SelectScope(Scope::Month)) + ); + } + + #[test] + fn card_click_is_ignored_when_cards_are_not_rendered() { + let area = Rect::new(0, 0, 80, 30); + + let mut hidden = SummaryCardVisibility::default(); + for scope in Scope::ALL { + hidden.toggle(scope); + } + assert_eq!( + mouse_action( + area, + hidden, + mouse(MouseEventKind::Down(MouseButton::Left), 5, 2) + ), + None + ); + } + + #[test] + fn wheel_only_scrolls_over_usage_table() { + let area = Rect::new(0, 0, 80, 30); + + assert_eq!( + mouse_action( + area, + SummaryCardVisibility::default(), + mouse(MouseEventKind::ScrollDown, 10, 10), + ), + Some(MouseAction::ScrollDown) + ); + assert_eq!( + mouse_action( + area, + SummaryCardVisibility::default(), + mouse(MouseEventKind::ScrollUp, 10, 2), + ), + None + ); + } + + #[test] + fn ignores_other_mouse_events() { + let area = Rect::new(0, 0, 80, 30); + + assert_eq!( + mouse_action( + area, + SummaryCardVisibility::default(), + mouse(MouseEventKind::Moved, 10, 10), + ), + None + ); + assert_eq!( + mouse_action( + area, + SummaryCardVisibility::default(), + mouse(MouseEventKind::Down(MouseButton::Right), 10, 2), + ), + None + ); + } +} diff --git a/src/tui/views/help.rs b/src/tui/views/help.rs index 91f918d..9a6a173 100644 --- a/src/tui/views/help.rs +++ b/src/tui/views/help.rs @@ -34,16 +34,21 @@ pub fn render(frame: &mut Frame) { let bindings = vec![ ("t / w / m / a", "Switch scope (Today/Week/Month/All)"), + ("T / W / M / A", "Toggle matching summary card only"), ("← / →", "Cycle scope left/right"), ("g", "Cycle group-by (model/client/both)"), ("h", "Toggle historical periods"), + ("c", "Open contribution heatmap"), + ("v", "Open token spike chart"), ("s", "Cycle sort (cost/tokens/name/reqs)"), ("/", "Filter by model/provider"), ("j / ↓", "Scroll table down"), ("k / ↑", "Scroll table up"), + ("Mouse", "Click scope cards / scroll table"), ("S", "Open settings editor"), ("?", "Toggle this help"), - ("q / Esc", "Quit (or clear filter)"), + ("Esc", "Back, clear filter, or quit"), + ("q", "Quit"), ("", ""), ("", "Data refreshes every tick interval."), ("", "Bar durations configurable via S."), diff --git a/src/tui/views/settings.rs b/src/tui/views/settings.rs index 449b33e..961f8ea 100644 --- a/src/tui/views/settings.rs +++ b/src/tui/views/settings.rs @@ -1,32 +1,56 @@ +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Flex, Layout, Rect}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use ratatui::Frame; +use crate::config::Config; use crate::tui::app::App; use crate::tui::settings_state::SettingField; use crate::tui::theme; +/// Actions that a pointer can perform in the settings overlay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MouseAction { + SelectField(usize), + ActivateField(usize), + ScrollUp, + ScrollDown, + ReviewSave, + ConfirmSave, + CancelSave, + Discard, + Close, +} + +#[derive(Debug, Clone, Copy)] +struct SettingsLayout { + popup: Rect, + inner: Rect, + content: Rect, + footer: Rect, + scroll_offset: usize, +} + /// Render the settings overlay as a centered popup. #[allow(clippy::too_many_lines)] pub fn render(frame: &mut Frame, app: &App) { let area = frame.area(); let state = &app.settings_state; - // Size: wide enough for labels + values, tall enough for all fields + sections + footer - let popup_width = area.width.min(60); - let popup_height = area.height.min(32); - - let popup_area = centered_rect(popup_width, popup_height, area); + let layout = settings_layout(area, app); + let popup_area = layout.popup; // Clear the area behind the popup frame.render_widget(Clear, popup_area); // Title with unsaved indicator - let title = if state.unsaved { - " Settings [modified] " + let title = if state.confirming_save { + " Save changes? ".to_string() + } else if state.unsaved { + " Settings [modified] ".to_string() } else { - " Settings " + " Settings ".to_string() }; let block = Block::default() @@ -39,12 +63,27 @@ pub fn render(frame: &mut Frame, app: &App) { })) .style(theme::card()); - let inner = block.inner(popup_area); + let inner = layout.inner; frame.render_widget(block, popup_area); // Build the content lines, tracking which line index each field maps to. let mut lines: Vec = Vec::with_capacity(SettingField::COUNT + 10); - let mut field_line_indices: Vec = Vec::with_capacity(SettingField::COUNT); + + let config_path = Config::config_path(); + let source_label = if config_path.exists() { + "saved configuration" + } else { + "built-in defaults (file will be created on save)" + }; + lines.push(Line::from(Span::styled( + format!(" Source: {source_label}"), + theme::text_dim(), + ))); + lines.push(Line::from(Span::styled( + format!(" Path: {}", config_path.display()), + theme::text_dim(), + ))); + lines.push(Line::from("")); for (idx, field) in SettingField::ALL.iter().enumerate() { // Section header @@ -58,8 +97,6 @@ pub fn render(frame: &mut Frame, app: &App) { ))); } - field_line_indices.push(lines.len()); // record the line index for this field - let is_selected = idx == state.selected; let value_str = if state.editing && is_selected { // Show edit buffer with cursor @@ -136,40 +173,26 @@ pub fn render(frame: &mut Frame, app: &App) { ))); } - // Split inner into scrollable content area and fixed footer - let footer_height: u16 = if state.unsaved { 3 } else { 2 }; - let content_height = inner.height.saturating_sub(footer_height); - let content_area = Rect::new(inner.x, inner.y, inner.width, content_height); - let footer_area = Rect::new( - inner.x, - inner.y + content_height, - inner.width, - footer_height, - ); - - // Determine scroll for content area - let visible_height = content_height as usize; - let selected_line_idx = field_line_indices.get(state.selected).copied().unwrap_or(0); - - let scroll_offset = if selected_line_idx >= visible_height { - selected_line_idx.saturating_sub(visible_height / 2) - } else { - 0 - }; - #[allow(clippy::cast_possible_truncation)] - let paragraph = Paragraph::new(lines).scroll((scroll_offset as u16, 0)); - frame.render_widget(paragraph, content_area); + let paragraph = Paragraph::new(lines).scroll((layout.scroll_offset as u16, 0)); + frame.render_widget(paragraph, layout.content); // Fixed footer — always visible let mut footer_lines: Vec = Vec::new(); // Unsaved changes prompt - if state.unsaved { + if state.confirming_save { + footer_lines.push(Line::from(vec![ + Span::styled(" Y/Enter", theme::status_key()), + Span::styled(": Confirm save ", theme::card_secondary()), + Span::styled("N/Esc", theme::status_key()), + Span::styled(": Cancel", theme::card_secondary()), + ])); + } else if state.unsaved { footer_lines.push(Line::from(vec![ Span::styled(" W", theme::status_key()), Span::styled( - ": Save changes ", + ": Review save ", ratatui::style::Style::default() .fg(theme::YELLOW) .bg(theme::SURFACE), @@ -207,7 +230,156 @@ pub fn render(frame: &mut Frame, app: &App) { } let footer_paragraph = Paragraph::new(footer_lines); - frame.render_widget(footer_paragraph, footer_area); + frame.render_widget(footer_paragraph, layout.footer); +} + +/// Translate a pointer event into a settings action using the same geometry as +/// the renderer. Pointer movement selects a field for visual feedback, while a +/// left click activates it just like Enter/Space. +#[must_use] +pub(crate) fn mouse_action(area: Rect, app: &App, event: MouseEvent) -> Option { + let layout = settings_layout(area, app); + + if let Some(field) = field_at(layout, event.column, event.row) { + return match event.kind { + MouseEventKind::Moved => Some(MouseAction::SelectField(field)), + MouseEventKind::Down(MouseButton::Left) => Some(MouseAction::ActivateField(field)), + MouseEventKind::ScrollUp => Some(MouseAction::ScrollUp), + MouseEventKind::ScrollDown => Some(MouseAction::ScrollDown), + _ => None, + }; + } + + if let Some(action) = footer_action( + layout, + app.settings_state.unsaved, + app.settings_state.confirming_save, + event, + ) { + return Some(action); + } + + if contains(layout.content, event.column, event.row) { + return match event.kind { + MouseEventKind::ScrollUp => Some(MouseAction::ScrollUp), + MouseEventKind::ScrollDown => Some(MouseAction::ScrollDown), + _ => None, + }; + } + + None +} + +fn settings_layout(area: Rect, app: &App) -> SettingsLayout { + let popup_width = area.width.min(80); + let popup_height = area.height.min(32); + let popup_area = centered_rect(popup_width, popup_height, area); + let inner = Rect::new( + popup_area.x.saturating_add(1), + popup_area.y.saturating_add(1), + popup_area.width.saturating_sub(2), + popup_area.height.saturating_sub(2), + ); + let footer_height: u16 = if app.settings_state.unsaved || app.settings_state.confirming_save { + 3 + } else { + 2 + }; + let content_height = inner.height.saturating_sub(footer_height); + let content = Rect::new(inner.x, inner.y, inner.width, content_height); + let footer = Rect::new( + inner.x, + inner.y.saturating_add(content_height), + inner.width, + footer_height, + ); + let selected_line = field_line_indices() + .get(app.settings_state.selected) + .copied() + .unwrap_or_default(); + let scroll_offset = if selected_line >= content_height as usize { + selected_line.saturating_sub(content_height as usize / 2) + } else { + 0 + }; + + SettingsLayout { + popup: popup_area, + inner, + content, + footer, + scroll_offset, + } +} + +fn field_line_indices() -> Vec { + let mut lines = 3; // source, path, blank + let mut indices = Vec::with_capacity(SettingField::COUNT); + for (idx, field) in SettingField::ALL.iter().enumerate() { + if field.section_header().is_some() { + if idx > 0 { + lines += 1; + } + lines += 1; + } + indices.push(lines); + lines += 1; + } + indices +} + +fn field_at(layout: SettingsLayout, column: u16, row: u16) -> Option { + if !contains(layout.content, column, row) { + return None; + } + let line = layout.scroll_offset + row.saturating_sub(layout.content.y) as usize; + field_line_indices().iter().position(|index| *index == line) +} + +fn footer_action( + layout: SettingsLayout, + unsaved: bool, + confirming_save: bool, + event: MouseEvent, +) -> Option { + if contains(layout.content, event.column, event.row) + || !contains(layout.footer, event.column, event.row) + { + return None; + } + let row = event.row.saturating_sub(layout.footer.y); + let midpoint = layout.footer.x + layout.footer.width / 2; + if row == 0 && matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + if confirming_save { + return Some(if event.column < midpoint { + MouseAction::ConfirmSave + } else { + MouseAction::CancelSave + }); + } + if unsaved { + return Some(if event.column < midpoint { + MouseAction::ReviewSave + } else { + MouseAction::Discard + }); + } + } + if !unsaved + && !confirming_save + && row == layout.footer.height.saturating_sub(1) + && matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) + { + return Some(MouseAction::Close); + } + None +} + +fn contains(area: Rect, column: u16, row: u16) -> bool { + column >= area.x + && column < area.x.saturating_add(area.width) + && row >= area.y + && row < area.y.saturating_add(area.height) } fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { @@ -220,3 +392,93 @@ fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { ); popup_area } + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + use ratatui::layout::Rect; + + use super::{field_at, field_line_indices, footer_action, MouseAction, SettingsLayout}; + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn visible_field_rows_resolve_and_headers_do_not() { + let layout = SettingsLayout { + popup: Rect::default(), + inner: Rect::default(), + content: Rect::new(5, 4, 30, 6), + footer: Rect::new(5, 10, 30, 2), + scroll_offset: field_line_indices()[0], + }; + + assert_eq!(field_at(layout, 6, 4), Some(0)); + assert_eq!(field_at(layout, 6, 5), None); + assert_eq!(field_at(layout, 4, 4), None); + } + + #[test] + fn scroll_offset_resolves_later_fields() { + let indices = field_line_indices(); + let layout = SettingsLayout { + popup: Rect::default(), + inner: Rect::default(), + content: Rect::new(0, 0, 20, 4), + footer: Rect::new(0, 4, 20, 2), + scroll_offset: indices[indices.len() - 1], + }; + + assert_eq!(field_at(layout, 10, 0), Some(indices.len() - 1)); + assert_eq!(field_at(layout, 21, 0), None); + } + + #[test] + fn footer_clicks_map_to_save_and_close_actions() { + let layout = SettingsLayout { + popup: Rect::default(), + inner: Rect::default(), + content: Rect::new(0, 0, 20, 4), + footer: Rect::new(0, 4, 20, 3), + scroll_offset: 0, + }; + assert_eq!( + footer_action( + layout, + true, + false, + mouse(MouseEventKind::Down(MouseButton::Left), 2, 4) + ), + Some(MouseAction::ReviewSave) + ); + assert_eq!( + footer_action( + layout, + true, + false, + mouse(MouseEventKind::Down(MouseButton::Left), 18, 4) + ), + Some(MouseAction::Discard) + ); + + let clean = SettingsLayout { + footer: Rect::new(0, 4, 20, 2), + ..layout + }; + assert_eq!( + footer_action( + clean, + false, + false, + mouse(MouseEventKind::Down(MouseButton::Left), 10, 5) + ), + Some(MouseAction::Close) + ); + } +} diff --git a/src/tui/widgets/heatmap.rs b/src/tui/widgets/heatmap.rs new file mode 100644 index 0000000..02c7d90 --- /dev/null +++ b/src/tui/widgets/heatmap.rs @@ -0,0 +1,309 @@ +use std::collections::{BTreeMap, HashMap}; + +use chrono::{Datelike, Duration, NaiveDate, Utc}; +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::display; +use crate::tui::theme; +use crate::types::Record; + +const LABEL_WIDTH: u16 = 4; +const WEEKS_IN_YEAR: usize = 53; + +/// Aggregated usage for one day in the contribution grid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeatmapDay { + pub date: NaiveDate, + pub total_tokens: u64, + pub dominant_provider: String, +} + +#[derive(Default)] +struct DayAccumulator { + total_tokens: u64, + provider_tokens: BTreeMap, +} + +/// Aggregate records into chronological per-day contributions. +#[must_use] +pub fn build_heatmap_data(records: &[Record]) -> Vec { + let mut days: BTreeMap = BTreeMap::new(); + + for record in records { + let tokens = record.total_tokens(); + if tokens == 0 { + continue; + } + + let provider = provider_name(record); + let day = days.entry(record.timestamp.date_naive()).or_default(); + day.total_tokens = day.total_tokens.saturating_add(tokens); + let entry = day.provider_tokens.entry(provider).or_default(); + *entry = entry.saturating_add(tokens); + } + + days.into_iter() + .map(|(date, day)| { + let dominant_provider = day + .provider_tokens + .into_iter() + .max_by(|(provider_a, tokens_a), (provider_b, tokens_b)| { + tokens_a + .cmp(tokens_b) + .then_with(|| provider_b.cmp(provider_a)) + }) + .map_or_else(|| "Other".to_string(), |(provider, _)| provider); + HeatmapDay { + date, + total_tokens: day.total_tokens, + dominant_provider, + } + }) + .collect() +} + +fn provider_name(record: &Record) -> String { + let provider = display::infer_api_provider(record.model.as_deref().unwrap_or("")); + if provider.is_empty() { + "Other".to_string() + } else { + provider.to_string() + } +} + +/// Render a responsive contribution heatmap for the twelve months ending today. +pub fn render(frame: &mut Frame, area: Rect, data: &[HeatmapDay]) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme::border()) + .title(Span::styled( + " Contributions · last 12 months ", + theme::header(), + )) + .style(theme::text()); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width < 18 || inner.height < 8 { + render_message(frame, inner, "Terminal too small for contribution heatmap"); + return; + } + if data.is_empty() { + render_message(frame, inner, "No usage data for the last 12 months"); + return; + } + + let today = Utc::now().date_naive(); + let available_width = inner.width.saturating_sub(LABEL_WIDTH); + let cell_width = u16::from(available_width >= (WEEKS_IN_YEAR as u16 * 2)); + let cell_width = cell_width + 1; + let visible_weeks = ((available_width / cell_width) as usize).min(WEEKS_IN_YEAR); + if visible_weeks == 0 { + render_message(frame, inner, "Terminal too small for contribution heatmap"); + return; + } + + let current_monday = monday_of_week(today); + let display_start = current_monday - Duration::weeks((visible_weeks - 1) as i64); + let day_map: HashMap = data.iter().map(|day| (day.date, day)).collect(); + let max_tokens = data + .iter() + .filter(|day| day.date >= display_start && day.date <= today) + .map(|day| day.total_tokens) + .max() + .unwrap_or(0); + + let month_labels = build_month_labels(display_start, visible_weeks, cell_width as usize); + frame.render_widget( + Line::from(vec![ + Span::raw(" ".repeat(LABEL_WIDTH as usize)), + Span::styled(month_labels, theme::text_dim()), + ]), + Rect::new(inner.x, inner.y, inner.width, 1), + ); + + let labels = ["Mon", "", "Wed", "", "Fri", "", ""]; + for (day_index, label) in labels.iter().enumerate() { + let mut spans = Vec::with_capacity(visible_weeks + 1); + spans.push(Span::styled( + format!("{label: today { + spans.push(Span::raw(" ".repeat(cell_width as usize))); + } else if let Some(day) = day_map.get(&cell_date) { + let level = intensity_level(day.total_tokens, max_tokens); + let color = intensity_color(theme::provider_color(&day.dominant_provider), level); + spans.push(Span::styled( + "█".repeat(cell_width as usize), + Style::default().fg(color), + )); + } else { + let empty = if cell_width == 1 { "·" } else { "· " }; + spans.push(Span::styled(empty, Style::default().fg(theme::BORDER))); + } + } + + frame.render_widget( + Line::from(spans), + Rect::new(inner.x, inner.y + 1 + day_index as u16, inner.width, 1), + ); + } +} + +fn render_message(frame: &mut Frame, area: Rect, message: &str) { + frame.render_widget(Paragraph::new(message).style(theme::text_dim()), area); +} + +fn monday_of_week(date: NaiveDate) -> NaiveDate { + date - Duration::days(i64::from(date.weekday().num_days_from_monday())) +} + +fn build_month_labels(start: NaiveDate, weeks: usize, cell_width: usize) -> String { + let mut line = vec![b' '; weeks * cell_width]; + let mut previous_month = None; + + for week in 0..weeks { + let date = start + Duration::weeks(week as i64); + if previous_month == Some(date.month()) { + continue; + } + previous_month = Some(date.month()); + let label = date.format("%b").to_string(); + let offset = week * cell_width; + for (index, byte) in label.bytes().enumerate() { + if let Some(slot) = line.get_mut(offset + index) { + *slot = byte; + } + } + } + + String::from_utf8(line).unwrap_or_default() +} + +fn intensity_level(tokens: u64, max_tokens: u64) -> u8 { + if tokens == 0 || max_tokens == 0 { + return 0; + } + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let level = ((tokens as f64 / max_tokens as f64).sqrt() * 4.0).ceil() as u8; + level.clamp(1, 4) +} + +fn intensity_color(color: Color, level: u8) -> Color { + let scale = match level { + 0 => 0.0, + 1 => 0.3, + 2 => 0.5, + 3 => 0.75, + _ => 1.0, + }; + match color { + Color::Rgb(red, green, blue) => + { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + Color::Rgb( + (f64::from(red) * scale) as u8, + (f64::from(green) * scale) as u8, + (f64::from(blue) * scale) as u8, + ) + } + other => other, + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use chrono::{TimeZone, Utc}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use super::*; + + fn record(day: u32, model: &str, input: u64, output: u64) -> Record { + Record { + timestamp: Utc.with_ymd_and_hms(2026, 7, day, 12, 0, 0).unwrap(), + provider: Cow::Borrowed("test"), + model: Some(model.to_string()), + input_tokens: input, + output_tokens: output, + cache_read_tokens: 0, + cache_creation_tokens: 0, + thinking_tokens: 0, + cost_usd: None, + message_id: None, + request_id: None, + session_id: None, + } + } + + #[test] + fn aggregates_days_and_selects_dominant_provider() { + let records = vec![ + record(2, "claude-opus-5", 100, 50), + record(2, "gpt-5", 400, 50), + record(1, "gemini-2.5-flash", 40, 10), + ]; + + let days = build_heatmap_data(&records); + + assert_eq!(days.len(), 2); + assert_eq!(days[0].date, NaiveDate::from_ymd_opt(2026, 7, 1).unwrap()); + assert_eq!(days[0].total_tokens, 50); + assert_eq!(days[0].dominant_provider, "Google"); + assert_eq!(days[1].total_tokens, 600); + assert_eq!(days[1].dominant_provider, "OpenAI"); + } + + #[test] + fn intensity_is_bounded_and_handles_empty_data() { + assert_eq!(intensity_level(0, 0), 0); + assert_eq!(intensity_level(1, 100), 1); + assert_eq!(intensity_level(25, 100), 2); + assert_eq!(intensity_level(100, 100), 4); + } + + #[test] + fn empty_and_small_render_areas_are_safe() { + for (width, height) in [(10, 3), (40, 12)] { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &[]); + }) + .unwrap(); + } + } + + #[test] + fn populated_heatmap_adapts_to_available_width() { + let data = [HeatmapDay { + date: Utc::now().date_naive(), + total_tokens: 1_000, + dominant_provider: "Anthropic".to_string(), + }]; + + for width in [20, 120] { + let backend = TestBackend::new(width, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, &data); + }) + .unwrap(); + } + } +} diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index 716ec10..a8b8c87 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -1,4 +1,6 @@ pub mod header; +pub mod heatmap; +pub mod spike_chart; pub mod status_bar; pub mod summary_cards; pub mod usage_table; diff --git a/src/tui/widgets/spike_chart.rs b/src/tui/widgets/spike_chart.rs new file mode 100644 index 0000000..cd03ca8 --- /dev/null +++ b/src/tui/widgets/spike_chart.rs @@ -0,0 +1,330 @@ +use std::collections::BTreeMap; + +use chrono::{DateTime, Duration, Timelike, Utc}; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::display; +use crate::render::format_tokens_short; +use crate::tui::theme; +use crate::types::Record; + +/// One token-usage bucket in the spike chart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpikeBucket { + pub input_tokens: u64, + pub output_tokens: u64, + pub dominant_provider: String, +} + +impl SpikeBucket { + #[must_use] + pub fn total_tokens(&self) -> u64 { + self.input_tokens.saturating_add(self.output_tokens) + } +} + +/// A bounded time series ready for responsive rendering. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpikeSeries { + pub start: DateTime, + pub bucket_seconds: u32, + pub buckets: Vec, +} + +/// Aggregate records into fixed-width time buckets. +#[must_use] +pub fn build_spike_data( + records: &[Record], + start: DateTime, + end: DateTime, + bucket_seconds: u32, +) -> SpikeSeries { + let bucket_seconds = bucket_seconds.max(1); + let span_seconds = (end - start).num_seconds().max(0); + let bucket_count = if span_seconds == 0 { + 0 + } else { + ((span_seconds + i64::from(bucket_seconds) - 1) / i64::from(bucket_seconds)) as usize + }; + let mut input = vec![0_u64; bucket_count]; + let mut output = vec![0_u64; bucket_count]; + let mut providers: Vec> = vec![BTreeMap::new(); bucket_count]; + + for record in records { + if record.timestamp < start || record.timestamp >= end { + continue; + } + let offset = (record.timestamp - start).num_seconds(); + let index = (offset / i64::from(bucket_seconds)) as usize; + if index >= bucket_count { + continue; + } + + let input_tokens = record + .input_tokens + .saturating_add(record.cache_read_tokens) + .saturating_add(record.cache_creation_tokens); + let output_tokens = record.output_tokens.saturating_add(record.thinking_tokens); + input[index] = input[index].saturating_add(input_tokens); + output[index] = output[index].saturating_add(output_tokens); + + let provider = provider_name(record); + let total = input_tokens.saturating_add(output_tokens); + let entry = providers[index].entry(provider).or_default(); + *entry = entry.saturating_add(total); + } + + let buckets = input + .into_iter() + .zip(output) + .zip(providers) + .map(|((input_tokens, output_tokens), provider_tokens)| { + let dominant_provider = provider_tokens + .into_iter() + .max_by(|(provider_a, tokens_a), (provider_b, tokens_b)| { + tokens_a + .cmp(tokens_b) + .then_with(|| provider_b.cmp(provider_a)) + }) + .map_or_else(|| "Other".to_string(), |(provider, _)| provider); + SpikeBucket { + input_tokens, + output_tokens, + dominant_provider, + } + }) + .collect(); + + SpikeSeries { + start, + bucket_seconds, + buckets, + } +} + +fn provider_name(record: &Record) -> String { + let provider = display::infer_api_provider(record.model.as_deref().unwrap_or("")); + if provider.is_empty() { + "Other".to_string() + } else { + provider.to_string() + } +} + +/// Render recent token volume as provider-coloured spikes. +pub fn render(frame: &mut Frame, area: Rect, series: Option<&SpikeSeries>) { + let bucket_label = series.map_or(5, |data| data.bucket_seconds / 60); + let block = Block::default() + .borders(Borders::ALL) + .border_style(theme::border()) + .title(Span::styled( + format!(" Token spikes · {bucket_label}m buckets "), + theme::header(), + )) + .style(theme::text()); + let inner = block.inner(area); + frame.render_widget(block, area); + + if inner.width < 12 || inner.height < 4 { + render_message(frame, inner, "Terminal too small for spike chart"); + return; + } + let Some(series) = series else { + render_message(frame, inner, "No token activity for today"); + return; + }; + + let visible_count = (inner.width as usize).min(series.buckets.len()); + let offset = series.buckets.len().saturating_sub(visible_count); + let visible = &series.buckets[offset..]; + let max_tokens = visible + .iter() + .map(SpikeBucket::total_tokens) + .max() + .unwrap_or(0); + if max_tokens == 0 { + render_message(frame, inner, "No token activity for today"); + return; + } + + let chart_height = inner.height.saturating_sub(1) as usize; + for row in 0..chart_height { + let mut spans = Vec::with_capacity(visible_count); + for bucket in visible { + let tokens = bucket.total_tokens(); + #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] + let height = + ((tokens as f64 / max_tokens as f64) * chart_height as f64).ceil() as usize; + let filled = height > 0 && row >= chart_height.saturating_sub(height); + if filled { + spans.push(Span::styled( + "█", + Style::default().fg(theme::provider_color(&bucket.dominant_provider)), + )); + } else { + spans.push(Span::raw(" ")); + } + } + frame.render_widget( + Line::from(spans), + Rect::new(inner.x, inner.y + row as u16, inner.width, 1), + ); + } + + let visible_start = + series.start + Duration::seconds((offset as i64) * i64::from(series.bucket_seconds)); + let totals = visible + .iter() + .fold((0_u64, 0_u64), |(input, output), bucket| { + ( + input.saturating_add(bucket.input_tokens), + output.saturating_add(bucket.output_tokens), + ) + }); + let footer = format!( + "{} in {} out {} → now", + visible_start.format("%H:%M"), + format_tokens_short(totals.0), + format_tokens_short(totals.1) + ); + frame.render_widget( + Paragraph::new(footer).style(theme::text_dim()), + Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(1), + inner.width, + 1, + ), + ); +} + +fn render_message(frame: &mut Frame, area: Rect, message: &str) { + frame.render_widget(Paragraph::new(message).style(theme::text_dim()), area); +} + +/// Return the start of the UTC day containing `timestamp`. +#[must_use] +pub fn start_of_day(timestamp: DateTime) -> DateTime { + timestamp + .with_hour(0) + .and_then(|value| value.with_minute(0)) + .and_then(|value| value.with_second(0)) + .and_then(|value| value.with_nanosecond(0)) + .unwrap_or(timestamp) +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use chrono::{TimeZone, Utc}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use super::*; + + fn record( + minute: u32, + model: &str, + input: u64, + output: u64, + cache: u64, + thinking: u64, + ) -> Record { + Record { + timestamp: Utc.with_ymd_and_hms(2026, 7, 25, 10, minute, 0).unwrap(), + provider: Cow::Borrowed("test"), + model: Some(model.to_string()), + input_tokens: input, + output_tokens: output, + cache_read_tokens: cache, + cache_creation_tokens: 0, + thinking_tokens: thinking, + cost_usd: None, + message_id: None, + request_id: None, + session_id: None, + } + } + + #[test] + fn buckets_records_and_counts_all_token_classes() { + let start = Utc.with_ymd_and_hms(2026, 7, 25, 10, 0, 0).unwrap(); + let end = Utc.with_ymd_and_hms(2026, 7, 25, 11, 0, 0).unwrap(); + let records = vec![ + record(0, "claude-opus-5", 100, 20, 30, 5), + record(14, "claude-opus-5", 10, 2, 3, 1), + record(15, "gpt-5", 200, 40, 0, 10), + record(59, "gemini-2.5-flash", 50, 5, 0, 0), + ]; + + let series = build_spike_data(&records, start, end, 15 * 60); + + assert_eq!(series.buckets.len(), 4); + assert_eq!(series.buckets[0].input_tokens, 143); + assert_eq!(series.buckets[0].output_tokens, 28); + assert_eq!(series.buckets[0].dominant_provider, "Anthropic"); + assert_eq!(series.buckets[1].total_tokens(), 250); + assert_eq!(series.buckets[3].dominant_provider, "Google"); + } + + #[test] + fn excludes_records_outside_the_requested_range() { + let start = Utc.with_ymd_and_hms(2026, 7, 25, 10, 0, 0).unwrap(); + let end = Utc.with_ymd_and_hms(2026, 7, 25, 10, 15, 0).unwrap(); + let records = vec![ + record(0, "gpt-5", 10, 0, 0, 0), + record(15, "gpt-5", 100, 0, 0, 0), + ]; + + let series = build_spike_data(&records, start, end, 15 * 60); + + assert_eq!(series.buckets.len(), 1); + assert_eq!(series.buckets[0].total_tokens(), 10); + } + + #[test] + fn empty_and_small_render_areas_are_safe() { + for (width, height) in [(8, 3), (40, 12)] { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, None); + }) + .unwrap(); + } + } + + #[test] + fn populated_spikes_adapt_to_available_width() { + let start = Utc.with_ymd_and_hms(2026, 7, 25, 10, 0, 0).unwrap(); + let series = SpikeSeries { + start, + bucket_seconds: 300, + buckets: (0..100) + .map(|index| SpikeBucket { + input_tokens: index * 10, + output_tokens: index * 5, + dominant_provider: "OpenAI".to_string(), + }) + .collect(), + }; + + for width in [20, 120] { + let backend = TestBackend::new(width, 12); + let mut terminal = Terminal::new(backend).unwrap(); + terminal + .draw(|frame| { + let area = frame.area(); + render(frame, area, Some(&series)); + }) + .unwrap(); + } + } +} diff --git a/src/tui/widgets/status_bar.rs b/src/tui/widgets/status_bar.rs index 543bb72..67cb60f 100644 --- a/src/tui/widgets/status_bar.rs +++ b/src/tui/widgets/status_bar.rs @@ -3,7 +3,7 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::Frame; -use crate::tui::app::App; +use crate::tui::app::{App, FullscreenView}; use crate::tui::theme; /// Render the bottom status bar with keybinding hints or filter input. @@ -51,17 +51,30 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { let sort_label = format!("sort:{}", app.sort_order.label()); let group_label = format!("group:{}", app.group_by.label()); - let bindings: Vec<(&str, &str)> = vec![ - ("t/w/m/a", "scope"), - ("g", &group_label), - ("h", "history"), - ("s", &sort_label), - ("/", "filter"), - ("j/k", "scroll"), - ("S", "settings"), - ("?", "help"), - ("q", "quit"), - ]; + let bindings: Vec<(&str, &str)> = if app.fullscreen == FullscreenView::None { + vec![ + ("t/w/m/a", "scope"), + ("T/W/M/A", "cards"), + ("g", &group_label), + ("h", "history"), + ("c", "calendar"), + ("v", "spikes"), + ("s", &sort_label), + ("/", "filter"), + ("j/k", "scroll"), + ("S", "settings"), + ("?", "help"), + ("q", "quit"), + ] + } else { + vec![ + ("c", "calendar"), + ("v", "spikes"), + ("Esc", "dashboard"), + ("?", "help"), + ("q", "quit"), + ] + }; for (i, (key, desc)) in bindings.iter().enumerate() { if i > 0 { diff --git a/src/tui/widgets/summary_cards.rs b/src/tui/widgets/summary_cards.rs index 2a6f32b..046e41d 100644 --- a/src/tui/widgets/summary_cards.rs +++ b/src/tui/widgets/summary_cards.rs @@ -4,54 +4,78 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Sparkline}; use ratatui::Frame; -use crate::tui::app::{App, Scope}; +use crate::tui::app::{App, HoverTarget, Scope, SummaryCardVisibility}; use crate::tui::theme; -/// Render the four summary cards: Today, This Week, This Month, All Time. -/// -/// Each card shows: -/// - Label (highlighted if it matches the active scope) -/// - Cost (large, bold) -/// - Token count (secondary) -/// - Sparkline (trend) +/// Render the visible summary cards: Today, This Week, This Month, All Time. pub fn render(frame: &mut Frame, area: Rect, app: &App) { - // Split into 4 equal columns - let [c1, c2, c3, c4] = Layout::horizontal([ - Constraint::Ratio(1, 4), - Constraint::Ratio(1, 4), - Constraint::Ratio(1, 4), - Constraint::Ratio(1, 4), - ]) - .areas(area); - - let scoped = [ - (Scope::Today, c1), - (Scope::Week, c2), - (Scope::Month, c3), - (Scope::AllTime, c4), - ]; let show_sparklines = app.config.show_sparklines; - for (i, &(scope, card_area)) in scoped.iter().enumerate() { + for (scope, card_area) in visible_card_areas(area, app.card_visibility) { render_card( frame, card_area, - &app.cards[i], + &app.cards[scope.card_index()], scope == app.scope, + matches!(app.hovered.as_ref(), Some(HoverTarget::Card(hovered)) if *hovered == scope), show_sparklines, ); } } +fn visible_card_areas(area: Rect, visibility: SummaryCardVisibility) -> Vec<(Scope, Rect)> { + let visible_scopes: Vec = Scope::ALL + .into_iter() + .filter(|scope| visibility.is_visible(*scope)) + .collect(); + let count = visibility.visible_count(); + if count == 0 { + return Vec::new(); + } + let constraints = vec![Constraint::Ratio(1, count as u32); count]; + let areas = Layout::horizontal(constraints).split(area); + visible_scopes + .into_iter() + .zip(areas.iter().copied()) + .collect() +} + +/// Return the visible scope card at a terminal coordinate, if any. +#[must_use] +pub(crate) fn scope_at( + area: Rect, + visibility: SummaryCardVisibility, + column: u16, + row: u16, +) -> Option { + visible_card_areas(area, visibility) + .into_iter() + .find_map(|(scope, card_area)| contains(card_area, column, row).then_some(scope)) +} + +fn contains(area: Rect, column: u16, row: u16) -> bool { + column >= area.x + && column < area.x.saturating_add(area.width) + && row >= area.y + && row < area.y.saturating_add(area.height) +} + fn render_card( frame: &mut Frame, area: Rect, card: &crate::tui::app::CardData, active: bool, + hovered: bool, show_sparklines: bool, ) { - // Card block with border + let surface = if hovered { + theme::SURFACE_HOVER + } else { + theme::SURFACE + }; let border_style = if active { theme::border().fg(theme::ACCENT) + } else if hovered { + theme::border().fg(theme::ACCENT_DIM) } else { theme::border() }; @@ -59,7 +83,7 @@ fn render_card( let block = Block::default() .borders(Borders::ALL) .border_style(border_style) - .style(theme::card()); + .style(theme::card().bg(surface)); let inner = block.inner(area); frame.render_widget(block, area); @@ -68,34 +92,30 @@ fn render_card( return; } - // Layout within card: label, cost, tokens, sparkline let constraints = if inner.height >= 5 { vec![ - Constraint::Length(1), // label - Constraint::Length(1), // cost - Constraint::Length(1), // tokens - Constraint::Min(1), // sparkline + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), ] } else if inner.height >= 3 { vec![ - Constraint::Length(1), // label - Constraint::Length(1), // cost - Constraint::Length(1), // tokens + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), ] } else { - vec![ - Constraint::Length(1), // label - Constraint::Length(1), // cost - ] + vec![Constraint::Length(1), Constraint::Length(1)] }; - let card_areas = Layout::vertical(constraints).split(inner); - // Label with trend indicator let label_style = if active { - theme::card_label().add_modifier(Modifier::UNDERLINED) - } else { theme::card_label() + .bg(surface) + .add_modifier(Modifier::UNDERLINED) + } else { + theme::card_label().bg(surface) }; let trend_color = match card.trend.cmp(&0) { std::cmp::Ordering::Greater => theme::GREEN, @@ -106,34 +126,32 @@ fn render_card( Span::styled(card.label, label_style), Span::styled( format!(" {}", card.trend_symbol()), - ratatui::style::Style::default() - .fg(trend_color) - .bg(theme::SURFACE), + ratatui::style::Style::default().fg(trend_color).bg(surface), ), ]); frame.render_widget(label, card_areas[0]); - // Cost - let cost_line = Line::from(Span::styled(card.cost_str(), theme::card_value())); + let cost_line = Line::from(Span::styled( + card.cost_str(), + theme::card_value().bg(surface), + )); frame.render_widget(cost_line, card_areas[1]); - // Tokens (if space) if card_areas.len() >= 3 { - let tokens_line = Line::from(Span::styled(card.tokens_str(), theme::card_secondary())); + let tokens_line = Line::from(Span::styled( + card.tokens_str(), + theme::card_secondary().bg(surface), + )); frame.render_widget(tokens_line, card_areas[2]); } - // Sparkline (if space and enabled) if card_areas.len() >= 4 && !card.sparkline.is_empty() && show_sparklines { - // Ratatui's Sparkline renders the FIRST N data points (N = widget width). - // We want to show the most recent data, so slice to the tail. let width = card_areas[3].width as usize; let data = if card.sparkline.len() > width { &card.sparkline[card.sparkline.len() - width..] } else { &card.sparkline }; - let sparkline = Sparkline::default().data(data).style( ratatui::style::Style::default() .fg(if active { @@ -141,8 +159,51 @@ fn render_card( } else { theme::ACCENT_DIM }) - .bg(theme::SURFACE), + .bg(surface), ); frame.render_widget(sparkline, card_areas[3]); } } + +#[cfg(test)] +mod tests { + use ratatui::layout::Rect; + + use super::{scope_at, visible_card_areas}; + use crate::tui::app::{Scope, SummaryCardVisibility}; + + #[test] + fn splits_card_area_into_scope_order() { + let area = Rect::new(4, 2, 80, 7); + let cards = visible_card_areas(area, SummaryCardVisibility::default()); + + assert_eq!(cards[0], (Scope::Today, Rect::new(4, 2, 20, 7))); + assert_eq!(cards[1], (Scope::Week, Rect::new(24, 2, 20, 7))); + assert_eq!(cards[2], (Scope::Month, Rect::new(44, 2, 20, 7))); + assert_eq!(cards[3], (Scope::AllTime, Rect::new(64, 2, 20, 7))); + } + + #[test] + fn hit_tests_card_boundaries() { + let area = Rect::new(0, 1, 80, 7); + let visibility = SummaryCardVisibility::default(); + + assert_eq!(scope_at(area, visibility, 0, 1), Some(Scope::Today)); + assert_eq!(scope_at(area, visibility, 19, 7), Some(Scope::Today)); + assert_eq!(scope_at(area, visibility, 20, 1), Some(Scope::Week)); + assert_eq!(scope_at(area, visibility, 79, 7), Some(Scope::AllTime)); + assert_eq!(scope_at(area, visibility, 80, 7), None); + assert_eq!(scope_at(area, visibility, 10, 8), None); + } + + #[test] + fn hidden_cards_reflow_hit_targets() { + let mut visibility = SummaryCardVisibility::default(); + visibility.toggle(Scope::Today); + + assert_eq!( + scope_at(Rect::new(0, 1, 60, 7), visibility, 1, 1), + Some(Scope::Week) + ); + } +} diff --git a/src/tui/widgets/usage_table.rs b/src/tui/widgets/usage_table.rs index 2409aad..acd5ed2 100644 --- a/src/tui/widgets/usage_table.rs +++ b/src/tui/widgets/usage_table.rs @@ -7,7 +7,7 @@ use ratatui::Frame; use crate::config::ColumnConfig; use crate::display; use crate::render::{format_cost, format_tokens_short}; -use crate::tui::app::App; +use crate::tui::app::{App, HoverTarget}; use crate::tui::diff::RowKey; use crate::tui::theme; @@ -131,6 +131,15 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { ), }; + let hovered = matches!( + app.hovered.as_ref(), + Some(HoverTarget::TableRow(hovered_key)) if hovered_key == &RowKey::from(mu) + ); + let sub_style = if hovered { + sub_style.bg(theme::SURFACE_HOVER) + } else { + sub_style + }; let sub_cells = cols.build_row( &name_col, &api_col, @@ -178,6 +187,15 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { let row_key = RowKey::from(mu); let intensity = app.highlight_intensity(&row_key); + let hovered = matches!( + app.hovered.as_ref(), + Some(HoverTarget::TableRow(hovered_key)) if hovered_key == &row_key + ); + let row_style = if hovered { + theme::text().bg(theme::SURFACE_HOVER) + } else { + theme::text() + }; let cells = cols.build_row( &name_col, &api_col, @@ -187,7 +205,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { mu.output_tokens, total, mu.cost_usd, - theme::text(), + row_style, true, intensity, ); @@ -218,6 +236,51 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { // This is a visual-only clamp; actual state clamping happens in app.rs } +/// Return the model row under a terminal coordinate, accounting for the +/// table header and current scroll offset. Non-model rows are not interactive. +#[must_use] +pub(crate) fn row_at(area: Rect, app: &App, column: u16, row: u16) -> Option { + let inner = Block::default().borders(Borders::ALL).inner(area); + if inner.height < 3 + || inner.width < 20 + || (app.detail_models.is_empty() && app.history_summaries.is_empty()) + || column < inner.x + || column >= inner.x.saturating_add(inner.width) + || row < inner.y.saturating_add(1) + || row >= inner.y.saturating_add(inner.height) + { + return None; + } + + let body_index = body_row_index(inner, app.scroll_offset, row)?; + row_keys(app).get(body_index).and_then(Clone::clone) +} + +fn body_row_index(inner: Rect, scroll_offset: u16, row: u16) -> Option { + (row >= inner.y.saturating_add(1) && row < inner.y.saturating_add(inner.height)).then(|| { + scroll_offset.saturating_add(row.saturating_sub(inner.y).saturating_sub(1)) as usize + }) +} + +fn row_keys(app: &App) -> Vec> { + let mut rows: Vec> = if app.show_history && !app.history_summaries.is_empty() { + app.history_summaries + .iter() + .flat_map(|summary| { + std::iter::once(None) + .chain(summary.models.iter().map(|model| Some(RowKey::from(model)))) + }) + .collect() + } else { + app.detail_models + .iter() + .map(|model| Some(RowKey::from(model))) + .collect() + }; + rows.push(None); // TOTAL row + rows +} + // ── Column management ───────────────────────────────────────────────────── /// Which columns to display, based on available width. @@ -500,6 +563,16 @@ fn apply_highlight(normal: Style, intensity: f64) -> Style { mod tests { use super::*; + #[test] + fn body_row_index_skips_header_and_applies_scroll() { + let inner = Rect::new(2, 4, 40, 8); + + assert_eq!(body_row_index(inner, 3, 4), None); + assert_eq!(body_row_index(inner, 3, 5), Some(3)); + assert_eq!(body_row_index(inner, 3, 11), Some(9)); + assert_eq!(body_row_index(inner, 3, 12), None); + } + #[test] fn masks_metric_columns_with_persisted_visibility() { let mut config = ColumnConfig::default(); diff --git a/src/types.rs b/src/types.rs index 7279640..7b81cbf 100644 --- a/src/types.rs +++ b/src/types.rs @@ -196,6 +196,37 @@ pub struct Report { pub total_tokens: u64, } +/// Stable machine-readable status document for lightweight clients. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusReport { + pub schema_version: u32, + pub generated_at: DateTime, + pub state: String, + pub scope: StatusScope, + pub providers: Vec, + pub summaries: Vec, + pub total_cost: f64, + pub total_tokens: u64, + pub total_requests: u64, + pub capabilities: StatusCapabilities, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusScope { + pub frequency: String, + pub since: Option, + pub until: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusCapabilities { + pub cost: bool, + pub date_filters: bool, + pub provider_filters: bool, + pub periodic_summaries: bool, + pub session_view: bool, +} + /// Summary for a single coding session #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionSummary { @@ -386,4 +417,42 @@ mod tests { assert_eq!(GroupBy::ModelClient.label(), "model+client"); assert_eq!(GroupBy::Client.label(), "client"); } + + #[test] + fn status_report_serializes_versioned_empty_state() { + let generated_at = Utc::now(); + let status = StatusReport { + schema_version: 1, + generated_at, + state: "empty".to_string(), + scope: StatusScope { + frequency: "daily".to_string(), + since: None, + until: None, + }, + providers: Vec::new(), + summaries: Vec::new(), + total_cost: 0.0, + total_tokens: 0, + total_requests: 0, + capabilities: StatusCapabilities { + cost: true, + date_filters: true, + provider_filters: true, + periodic_summaries: true, + session_view: true, + }, + }; + + let value = serde_json::to_value(&status).expect("status should serialize"); + assert_eq!(value["schema_version"], 1); + assert_eq!(value["state"], "empty"); + assert_eq!(value["scope"]["frequency"], "daily"); + assert_eq!(value["providers"], serde_json::json!([])); + assert_eq!(value["total_tokens"], 0); + assert_eq!(value["total_requests"], 0); + assert!(value["capabilities"]["periodic_summaries"] + .as_bool() + .unwrap_or(false)); + } } diff --git a/tests/fixtures/continue_tokens.jsonl b/tests/fixtures/continue_tokens.jsonl new file mode 100644 index 0000000..b58a7ec --- /dev/null +++ b/tests/fixtures/continue_tokens.jsonl @@ -0,0 +1,6 @@ +{"timestamp":"2026-07-24T10:30:00.000Z","eventName":"tokensGenerated","schema":"0.2.0","model":"claude-sonnet-4","provider":"anthropic","promptTokens":1234,"generatedTokens":321} +{"timestamp":"2026-07-24T11:00:00+00:00","eventName":"tokensGenerated","schema":"0.2.0","model":"gemini-2.5-pro","provider":"vertexai","promptTokens":800,"generatedTokens":200} +{"timestamp":"2026-07-24T11:10:00Z","eventName":"autocomplete","schema":"0.2.0","model":"ignored","provider":"custom"} +{"timestamp":"not-a-timestamp","eventName":"tokensGenerated","schema":"0.2.0","model":"gpt-4o","provider":"openai","promptTokens":100,"generatedTokens":50} +{"timestamp":"2026-07-24T12:00:00Z","eventName":"tokensGenerated","schema":"0.2.0","model":"ignored","provider":"custom","promptTokens":0,"generatedTokens":0} +{"timestamp":"2026-07-24T12:30:00Z","promptTokens":1,"generatedTokens": diff --git a/tests/integration.rs b/tests/integration.rs index 97b9735..33705ae 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -111,6 +111,25 @@ fn test_cline_parse_fixture() { assert_eq!(entries[1].cost_usd, Some(0.025)); } +#[test] +fn test_continue_parse_fixture() { + let provider = tokemon::source::continue_dev::ContinueSource::new(); + let path = Path::new("tests/fixtures/continue_tokens.jsonl"); + let entries = provider.parse_file(path).unwrap(); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].provider, "continue"); + assert_eq!( + entries[0].model.as_deref(), + Some("anthropic/claude-sonnet-4") + ); + assert_eq!(entries[0].input_tokens, 1_234); + assert_eq!(entries[0].output_tokens, 321); + assert_eq!(entries[1].model.as_deref(), Some("vertexai.gemini-2.5-pro")); + assert_eq!(entries[1].input_tokens, 800); + assert_eq!(entries[1].output_tokens, 200); +} + #[test] fn test_daily_aggregation() { let provider = tokemon::source::claude_code::ClaudeCodeSource::new();