From e1a3612d613570afb14154e869387eae4768d748 Mon Sep 17 00:00:00 2001 From: mm65x Date: Thu, 12 Mar 2026 22:38:45 +0000 Subject: [PATCH 01/29] docs: sync architecture diagram and component guides with recent codebase refactoring --- CLAUDE.md | 2 +- README.md | 25 ++++++++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d1b8e2..187607b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Creates a GitHub Release marked as pre-release. Does NOT publish to crates.io. ## Code Conventions - **New JSONL sources**: Implement `JsonlSourceConfig` (~15 lines) and use `JsonlSource` from `source/jsonl_source.rs` -- **Cline-derived sources**: Use `ClineFormat` from `source/cline_format.rs` +- **Cline-derived sources**: Implement `ClineSourceConfig` and use `ClineDerivedSource` from `source/cline_format.rs` - **SQLite sources**: See `source/opencode.rs` for the pattern — open read-only, busy_timeout, `json_extract` for JSON columns - **Timestamps**: Always use `timestamp::parse_timestamp()`, never inline parsing - **File discovery**: Each `Source` implements `discover_files()` using helpers from `source/discover.rs` (`collect_by_ext`, `walk_by_ext`). No glob crate — use bounded `read_dir` walking only. diff --git a/README.md b/README.md index 7ede35b..bce2ecf 100644 --- a/README.md +++ b/README.md @@ -207,29 +207,36 @@ make ci # Run all checks (fmt + lint + test) ``` src/ -├── main.rs # CLI entry, command dispatch, cache-aware parsing +├── main.rs # CLI entry and command dispatch +├── lib.rs # Library entry point ├── cli.rs # clap argument definitions ├── config.rs # TOML config loading and validation -├── types.rs # Core data types (Record, Report, etc.) +├── types.rs # Core data types (Record, ModelUsage, etc.) ├── error.rs # Error types ├── cache.rs # SQLite cache layer +├── pipeline.rs # Shared data loading orchestration (used by CLI and MCP) ├── display.rs # Name translation (client, model, API provider) ├── pacemaker.rs # Budget tracking and limits ├── timestamp.rs # Shared timestamp parsing -├── cost.rs # LiteLLM cost calculation engine +├── cost.rs # Pricing engine ├── rollup.rs # Daily/weekly/monthly grouping ├── dedup.rs # Hash-based deduplication -├── render.rs # Table and JSON rendering with responsive columns +├── render/ # Table, CSV, and JSON rendering +├── tui/ # Terminal UI dashboard (`tokemon top`) +│ ├── app.rs # Core state and event loop +│ ├── watcher.rs # Background file modification watcher +│ ├── settings_state.rs# Configuration settings state +│ ├── sparkline_data.rs# Rendering sparklines +│ ├── theme.rs # TUI color palette +│ └── widgets/ # TUI components (usage table, summary cards) ├── mcp.rs # MCP server (Model Context Protocol) ├── paths.rs # Platform-specific path resolution └── source/ ├── mod.rs # Source trait and SourceSet ├── discover.rs # Bounded read_dir file discovery utilities - ├── jsonl_source.rs # Generic JSONL source (4 sources use this) - ├── cline_format.rs # Shared Cline-format parser (3 sources use this) - ├── claude_code.rs # Claude Code parser (structural discovery) - ├── codex.rs # Codex CLI parser (state machine, YYYY/MM/DD nav) - └── ... # One file per source + ├── jsonl_source.rs # Generic JSONL source + ├── cline_format.rs # Shared Cline-format parser + └── ... # One file per provider ``` ## License From f5c7bb712162d079f9883d5c4905f44860f81b85 Mon Sep 17 00:00:00 2001 From: mm65x Date: Thu, 12 Mar 2026 22:45:10 +0000 Subject: [PATCH 02/29] chore: bump version to v0.2.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ead94f7..d489acc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "tokemon" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index e8270b1..e2a958d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tokemon" -version = "0.2.3" +version = "0.2.4" edition = "2021" description = "Unified LLM token usage tracking across all providers" license = "MIT" From 119d76e48346f1eaa91f6011a100a5ae03d46ecb Mon Sep 17 00:00:00 2001 From: mm65x Date: Thu, 12 Mar 2026 22:51:45 +0000 Subject: [PATCH 03/29] chore: remove dangling empty doc comments --- src/tui/theme.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 74214bc..c5b0838 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -112,9 +112,6 @@ pub fn cost_color(cost: f64) -> Color { } } -/// Cost styling based on value. -#[must_use] - /// Token foreground color based on value (dim for zeros). pub fn tokens_color(n: u64) -> Color { if n == 0 { @@ -125,9 +122,6 @@ pub fn tokens_color(n: u64) -> Color { } /// Token count styling — dim for zeros. -#[must_use] - -/// Surface panel style (for cards). pub fn card() -> Style { Style::default().fg(FG).bg(SURFACE) } From ecfa55eb2629935e9c3c097d3ba8001c17fd5333 Mon Sep 17 00:00:00 2001 From: mm65x Date: Fri, 13 Mar 2026 12:05:03 +0000 Subject: [PATCH 04/29] fix(tui): enable full group-by column resolution in history mode - Fixed an issue where the history sub-rows aggressively hardcoded the `Client` column to an empty string. - The history mode now routes through the exact same `GroupBy` matcher logic as the normal view, dynamically showing or hiding the Model/API/Client headers completely symmetrically with the main TUI tables. - Regroups the atomic models inside each historical period summary dynamically when the user cycles the `g` grouping hotkey. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/tui/app.rs | 6 +++++- src/tui/widgets/usage_table.rs | 25 ++++++++++++++++++++++--- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d489acc..42fc7e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "tokemon" -version = "0.2.4" +version = "0.2.5" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index e2a958d..b50ffc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tokemon" -version = "0.2.4" +version = "0.2.5" edition = "2021" description = "Unified LLM token usage tracking across all providers" license = "MIT" diff --git a/src/tui/app.rs b/src/tui/app.rs index 017965e..02112b3 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -960,8 +960,12 @@ impl App { // Sort dates descending so newest periods appear at the top self.history_summaries.sort_by(|a, b| b.date.cmp(&a.date)); - // Sort models within each period deterministically + // Regroup and sort models within each period based on current settings for summary in &mut self.history_summaries { + summary.models = rollup::aggregate_summaries_to_models( + std::slice::from_ref(summary), + self.group_by, + ); sort_models(&mut summary.models, self.sort_order); } } else { diff --git a/src/tui/widgets/usage_table.rs b/src/tui/widgets/usage_table.rs index 9298797..5c64cae 100644 --- a/src/tui/widgets/usage_table.rs +++ b/src/tui/widgets/usage_table.rs @@ -112,10 +112,29 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { 0.0 }; + // Columns depend on group-by mode (same as normal view) + let (name_col, api_col, client_col) = match app.group_by { + crate::types::GroupBy::Model => ( + format!(" {}", display::display_model(&mu.model)), + display::infer_api_provider(mu.effective_raw_model()).to_string(), + String::new(), + ), + crate::types::GroupBy::ModelClient => ( + format!(" {}", display::display_model(&mu.model)), + display::infer_api_provider(mu.effective_raw_model()).to_string(), + display::display_client(&mu.provider).into_owned(), + ), + crate::types::GroupBy::Client => ( + format!(" {}", display::display_client(&mu.provider)), + String::new(), + String::new(), + ), + }; + let sub_cells = cols.build_row( - &format!(" {}", display::display_model(&mu.model)), - display::infer_api_provider(mu.effective_raw_model()), - "", + &name_col, + &api_col, + &client_col, mu.request_count, mu.input_tokens, mu.output_tokens, From 72fb87738e6afbc80710de6a3a3ef29957be6c61 Mon Sep 17 00:00:00 2001 From: mm65x Date: Mon, 16 Mar 2026 18:12:06 +0000 Subject: [PATCH 05/29] chore: add CODEOWNERS file --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3b0351a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @mm65x From 3ec1b3eccfa920c42dcc6e190eedf043746c7529 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 22 Mar 2026 11:03:52 +0000 Subject: [PATCH 06/29] fix: add missing must_use attrs and fix doc comment on card() --- src/tui/theme.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tui/theme.rs b/src/tui/theme.rs index c5b0838..0f23740 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -113,6 +113,7 @@ pub fn cost_color(cost: f64) -> Color { } /// Token foreground color based on value (dim for zeros). +#[must_use] pub fn tokens_color(n: u64) -> Color { if n == 0 { DIM @@ -121,7 +122,8 @@ pub fn tokens_color(n: u64) -> Color { } } -/// Token count styling — dim for zeros. +/// Surface panel style (for cards). +#[must_use] pub fn card() -> Style { Style::default().fg(FG).bg(SURFACE) } From 9fa1ee51173a178698ded616a63b719c3a93ae68 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 22 Mar 2026 13:49:23 +0000 Subject: [PATCH 07/29] fix: re-price zero-cost records and improve error resilience (#14) * fix: re-price records with zero cost instead of preserving them Source parsers (e.g. OpenCode) store cost: 0 for models they can't price. The apply_costs guard treated Some(0.0) as 'already priced', permanently locking these records at $0. Now only positive costs are preserved. * fix: stop silently truncating files on I/O errors and log skipped rows - Changed map_while(Result::ok) to filter_map with error counters in claude_code.rs, pi_agent.rs, and jsonl_source.rs so a single bad line doesn't abort the entire file parse. - Added skip counter to mark_preserved in cache.rs for row-level errors. --------- Co-authored-by: mm65x --- src/cache.rs | 14 +++++- src/cost.rs | 95 ++++++++++++++++++++++++++++++++++++-- src/source/claude_code.rs | 37 +++++++++++---- src/source/jsonl_source.rs | 37 +++++++++++---- src/source/pi_agent.rs | 24 ++++++---- 5 files changed, 178 insertions(+), 29 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index b05dadf..056b400 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -423,7 +423,19 @@ impl Cache { .prepare("SELECT DISTINCT source_file FROM usage_entries WHERE preserved = 0")?; let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; - let cached_files: Vec = rows.flatten().collect(); + let mut skipped = 0u64; + let mut cached_files = Vec::new(); + for row in rows { + match row { + Ok(file) => cached_files.push(file), + Err(_) => skipped += 1, + } + } + if skipped > 0 { + eprintln!( + "[tokemon] Warning: skipped {skipped} cached rows while checking preserved files" + ); + } for file in &cached_files { if !discovered_files.contains(file) { self.conn.execute( diff --git a/src/cost.rs b/src/cost.rs index b688fb2..ed2c591 100644 --- a/src/cost.rs +++ b/src/cost.rs @@ -107,11 +107,15 @@ impl PricingEngine { let mut pricing_cache: HashMap<&str, Option<&ModelPricing>> = HashMap::new(); for entry in entries.iter_mut() { - // If entry already has a cost (even $0.00), keep it. - // Some(0.0) means "already priced, result was zero" (e.g. - // free model or no pricing data). Re-pricing would cause + // Skip records that already have a positive cost — these were + // priced correctly on a previous run and re-pricing would cause // cost fluctuations when records are loaded from cache. - if entry.cost_usd.is_some() { + // + // Records with `Some(0.0)` are treated as *unpriced*: some + // source parsers store `cost: 0` when they don't know the + // price for a model, so we give the pricing engine a chance + // to fill in the real cost. + if entry.cost_usd.is_some_and(|c| c > 0.0) { continue; } @@ -368,4 +372,87 @@ mod tests { .expect("should prefix match gpt-4-32k"); assert_eq!(p2.input_cost_per_token, Some(0.06)); } + + #[test] + fn test_zero_cost_gets_repriced() { + use chrono::Utc; + use std::borrow::Cow; + + let engine = PricingEngine::parse_pricing(DUMMY_JSON).unwrap(); + + let mut records = vec![ + // Record with cost_usd = Some(0.0) should be re-priced + Record { + timestamp: Utc::now(), + provider: Cow::Borrowed("test"), + model: Some("model-a".to_string()), + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 0, + cache_creation_tokens: 0, + thinking_tokens: 0, + cost_usd: Some(0.0), + message_id: None, + request_id: None, + session_id: None, + }, + // Record with a positive cost should be kept as-is + Record { + timestamp: Utc::now(), + provider: Cow::Borrowed("test"), + model: Some("model-a".to_string()), + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 0, + cache_creation_tokens: 0, + thinking_tokens: 0, + cost_usd: Some(99.0), + message_id: None, + request_id: None, + session_id: None, + }, + // Record with cost_usd = None should also be priced + Record { + timestamp: Utc::now(), + provider: Cow::Borrowed("test"), + model: Some("model-a".to_string()), + input_tokens: 1000, + output_tokens: 500, + cache_read_tokens: 0, + cache_creation_tokens: 0, + thinking_tokens: 0, + cost_usd: None, + message_id: None, + request_id: None, + session_id: None, + }, + ]; + + engine.apply_costs(&mut records); + + // model-a: input=0.001, output=0.002 + // expected = 1000 * 0.001 + 500 * 0.002 = 1.0 + 1.0 = 2.0 + let expected_cost = 2.0; + + // Some(0.0) record got re-priced + assert_eq!( + records[0].cost_usd, + Some(expected_cost), + "record with cost_usd=Some(0.0) should be re-priced" + ); + + // Positive cost record kept original value + assert_eq!( + records[1].cost_usd, + Some(99.0), + "record with positive cost should not be re-priced" + ); + + // None record got priced + assert_eq!( + records[2].cost_usd, + Some(expected_cost), + "record with cost_usd=None should be priced" + ); + } } diff --git a/src/source/claude_code.rs b/src/source/claude_code.rs index 123ae7b..2dca469 100644 --- a/src/source/claude_code.rs +++ b/src/source/claude_code.rs @@ -102,22 +102,30 @@ impl super::Source for ClaudeCodeSource { let reader = BufReader::with_capacity(64 * 1024, file); let session_id = timestamp::extract_session_id(path); - let mut error_logged = false; + let mut io_errors = 0u64; + let mut json_errors = 0u64; let entries = reader .lines() - .map_while(std::result::Result::ok) - .filter(|line| line.contains("\"assistant\"")) - .filter_map(|line| match serde_json::from_str::(&line) { - Ok(parsed) => Some(parsed), + .filter_map(|r| match r { + Ok(line) => Some(line), Err(e) => { - if !error_logged { + if io_errors == 0 { eprintln!( - "[tokemon] Warning: skipped malformed JSON in {}: {}", + "[tokemon] Warning: I/O error reading {}: {}", path.display(), e ); - error_logged = true; } + io_errors += 1; + None + } + }) + .filter(|line| line.contains("\"assistant\"")) + .filter_map(|line| { + if let Ok(parsed) = serde_json::from_str::(&line) { + Some(parsed) + } else { + json_errors += 1; None } }) @@ -163,6 +171,19 @@ impl super::Source for ClaudeCodeSource { }) .collect(); + 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() + ); + } + Ok(entries) } } diff --git a/src/source/jsonl_source.rs b/src/source/jsonl_source.rs index 9d7a974..3994ee2 100644 --- a/src/source/jsonl_source.rs +++ b/src/source/jsonl_source.rs @@ -101,22 +101,30 @@ impl super::Source for JsonlSource { let reader = BufReader::with_capacity(64 * 1024, file); let session_id = timestamp::extract_session_id(path); - let mut error_logged = false; + let mut io_errors = 0u64; + let mut json_errors = 0u64; let entries = reader .lines() - .map_while(std::result::Result::ok) - .filter(|line| line.contains("\"assistant\"") || line.contains("\"response\"")) - .filter_map(|line| match serde_json::from_str::(&line) { - Ok(parsed) => Some(parsed), + .filter_map(|r| match r { + Ok(line) => Some(line), Err(e) => { - if !error_logged { + if io_errors == 0 { eprintln!( - "[tokemon] Warning: skipped malformed JSON in {}: {}", + "[tokemon] Warning: I/O error reading {}: {}", path.display(), e ); - error_logged = true; } + io_errors += 1; + None + } + }) + .filter(|line| line.contains("\"assistant\"") || line.contains("\"response\"")) + .filter_map(|line| { + if let Ok(parsed) = serde_json::from_str::(&line) { + Some(parsed) + } else { + json_errors += 1; None } }) @@ -161,6 +169,19 @@ impl super::Source for JsonlSource { }) .collect(); + 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() + ); + } + Ok(entries) } } diff --git a/src/source/pi_agent.rs b/src/source/pi_agent.rs index 1eaa9fd..d382212 100644 --- a/src/source/pi_agent.rs +++ b/src/source/pi_agent.rs @@ -76,22 +76,30 @@ impl super::Source for PiAgentSource { let reader = BufReader::with_capacity(64 * 1024, file); let session_id = timestamp::extract_session_id(path); - let mut error_logged = false; + let mut io_errors = 0u64; + let mut json_errors = 0u64; let entries = reader .lines() - .map_while(std::result::Result::ok) - .filter(|line| line.contains("\"message\"") && line.contains("\"assistant\"")) - .filter_map(|line| match serde_json::from_str::(&line) { - Ok(parsed) => Some(parsed), + .filter_map(|r| match r { + Ok(line) => Some(line), Err(e) => { - if !error_logged { + if io_errors == 0 { eprintln!( - "[tokemon] Warning: skipped malformed JSON in {}: {}", + "[tokemon] Warning: I/O error reading {}: {}", path.display(), e ); - error_logged = true; } + io_errors += 1; + None + } + }) + .filter(|line| line.contains("\"message\"") && line.contains("\"assistant\"")) + .filter_map(|line| { + if let Ok(parsed) = serde_json::from_str::(&line) { + Some(parsed) + } else { + json_errors += 1; None } }) From d8974b591545d57ff1805f6d0b7e431e8803b357 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 25 Jul 2026 22:31:41 +0100 Subject: [PATCH 08/29] feat: toggle summary cards in top view --- src/tui/app.rs | 118 ++++++++++++++++++++++++++++++- src/tui/views/dashboard.rs | 40 ++++++++--- src/tui/views/help.rs | 1 + src/tui/widgets/status_bar.rs | 1 + src/tui/widgets/summary_cards.rs | 30 ++++---- 5 files changed, 164 insertions(+), 26 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 02112b3..442ffb5 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -32,6 +32,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 { @@ -52,11 +54,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, @@ -147,6 +204,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. @@ -254,6 +313,7 @@ impl App { trend: 0, }, ], + card_visibility: SummaryCardVisibility::default(), detail_models: Vec::new(), detail_total_cost: 0.0, detail_total_tokens: 0, @@ -396,6 +456,13 @@ impl App { return self.handle_filter_key(key); } + 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() { @@ -1028,3 +1095,52 @@ fn load_records_from_cache(pricing: Option<&cost::PricingEngine>) -> Vec entries.sort_by_key(|e| e.timestamp); entries } + +#[cfg(test)] +mod tests { + use super::{Scope, SummaryCardVisibility}; + + #[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/views/dashboard.rs b/src/tui/views/dashboard.rs index 4b16396..a59bb58 100644 --- a/src/tui/views/dashboard.rs +++ b/src/tui/views/dashboard.rs @@ -24,14 +24,7 @@ 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 card_height = summary_card_height(area.height, app.card_visibility.any_visible()); let mut constraints = vec![ Constraint::Length(1), // header @@ -73,3 +66,34 @@ 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 + } +} + +#[cfg(test)] +mod tests { + use super::summary_card_height; + + #[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); + } +} diff --git a/src/tui/views/help.rs b/src/tui/views/help.rs index 91f918d..6a0e16d 100644 --- a/src/tui/views/help.rs +++ b/src/tui/views/help.rs @@ -34,6 +34,7 @@ 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"), diff --git a/src/tui/widgets/status_bar.rs b/src/tui/widgets/status_bar.rs index 82e1964..6a9f69f 100644 --- a/src/tui/widgets/status_bar.rs +++ b/src/tui/widgets/status_bar.rs @@ -53,6 +53,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { let group_label = format!("group:{}", app.group_by.label()); let bindings: Vec<(&str, &str)> = vec![ ("t/w/m/a", "scope"), + ("T/W/M/A", "cards"), ("g", &group_label), ("h", "history"), ("s", &sort_label), diff --git a/src/tui/widgets/summary_cards.rs b/src/tui/widgets/summary_cards.rs index 2a6f32b..1d909c5 100644 --- a/src/tui/widgets/summary_cards.rs +++ b/src/tui/widgets/summary_cards.rs @@ -7,7 +7,7 @@ use ratatui::Frame; use crate::tui::app::{App, Scope}; use crate::tui::theme; -/// Render the four summary cards: Today, This Week, This Month, All Time. +/// Render the visible summary cards: Today, This Week, This Month, All Time. /// /// Each card shows: /// - Label (highlighted if it matches the active scope) @@ -15,27 +15,23 @@ use crate::tui::theme; /// - Token count (secondary) /// - Sparkline (trend) 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 visible_count = app.card_visibility.visible_count(); + if visible_count == 0 { + return; + } - let scoped = [ - (Scope::Today, c1), - (Scope::Week, c2), - (Scope::Month, c3), - (Scope::AllTime, c4), - ]; + let visible_scopes: Vec = Scope::ALL + .into_iter() + .filter(|scope| app.card_visibility.is_visible(*scope)) + .collect(); + let constraints = vec![Constraint::Ratio(1, visible_count as u32); visible_count]; + let card_areas = Layout::horizontal(constraints).split(area); let show_sparklines = app.config.show_sparklines; - for (i, &(scope, card_area)) in scoped.iter().enumerate() { + for (scope, card_area) in visible_scopes.into_iter().zip(card_areas.iter().copied()) { render_card( frame, card_area, - &app.cards[i], + &app.cards[scope.card_index()], scope == app.scope, show_sparklines, ); From 9314335da0303dfb314843d8c54f5bcdc371b5e3 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 25 Jul 2026 22:34:10 +0100 Subject: [PATCH 09/29] feat(tui): add mouse navigation support --- src/tui/app.rs | 89 +++++++++----- src/tui/event.rs | 80 +++++++++++-- src/tui/mod.rs | 8 +- src/tui/views/dashboard.rs | 195 ++++++++++++++++++++++++++----- src/tui/views/help.rs | 1 + src/tui/widgets/summary_cards.rs | 82 ++++++++++--- 6 files changed, 370 insertions(+), 85 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 02112b3..a4c7d11 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -3,7 +3,8 @@ use std::time::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,7 @@ use crate::{cache, cost, dedup, rollup}; use super::diff::{self, RowKey}; use super::event::Event; +use super::views::dashboard::{self, MouseAction}; /// Duration (in seconds) for the per-cell highlight fade animation. const HIGHLIGHT_DURATION_SECS: f64 = 1.5; @@ -20,6 +22,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; + // ── View scope ──────────────────────────────────────────────────────────── /// Which time window the detail table shows. @@ -317,13 +322,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. @@ -366,6 +376,19 @@ impl App { } } + fn handle_mouse(&mut self, mouse: MouseEvent, terminal_area: Rect) -> bool { + if self.show_settings || self.show_help || self.filter_active { + return false; + } + + match dashboard::mouse_action(terminal_area, 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, + } + } + /// Returns the current warning message if it's still fresh (< 5 seconds old). #[must_use] pub fn active_warning(&self) -> Option<&str> { @@ -422,26 +445,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(); @@ -460,15 +467,8 @@ impl App { self.recompute_detail(); true } - KeyCode::Char('j') | KeyCode::Down => { - let max = self.detail_models.len().saturating_sub(1) as u16; - self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); - true - } - KeyCode::Char('k') | KeyCode::Up => { - self.scroll_offset = self.scroll_offset.saturating_sub(1); - 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, @@ -501,6 +501,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 = self.detail_models.len().saturating_sub(1) as u16; + 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 => { diff --git a/src/tui/event.rs b/src/tui/event.rs index e3f33d9..c5ac6d6 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 or the wheel was scrolled. + 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,69 @@ 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 + ) => + { + 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 ignores_mouse_motion() { + let mouse = MouseEvent { + kind: MouseEventKind::Moved, + column: 12, + row: 4, + modifiers: KeyModifiers::NONE, + }; + + assert!(map_crossterm_event(&CrosstermEvent::Mouse(mouse)).is_none()); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d31a5a7..b052008 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}; @@ -63,6 +65,7 @@ async fn run_async( offline: bool, ) -> anyhow::Result<()> { let mut terminal = terminal::init()?; + let mut terminal_area = Rect::from(terminal.size()?); let mut app = App::new(config, scope, offline); let mut events = EventHandler::new( @@ -87,7 +90,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/views/dashboard.rs b/src/tui/views/dashboard.rs index 4b16396..1fe180c 100644 --- a/src/tui/views/dashboard.rs +++ b/src/tui/views/dashboard.rs @@ -1,4 +1,5 @@ -use ratatui::layout::{Constraint, Layout}; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::widgets::Block; use ratatui::Frame; @@ -7,6 +8,21 @@ use crate::tui::theme; use crate::tui::views::{help, settings}; use crate::tui::widgets::{header, 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: @@ -24,52 +40,173 @@ 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 layout = dashboard_layout(area); + + // Header + header::render(frame, layout.header, app); + + // Summary cards (if space) + if let Some(cards_area) = layout.summary_cards { + summary_cards::render(frame, cards_area, app); + } + + // Usage table + usage_table::render(frame, layout.usage_table, app); + + // Status bar + status_bar::render(frame, layout.status_bar, app); + + // Overlays (rendered on top of everything) + if app.show_help { + help::render(frame); + } + if app.show_settings { + settings::render(frame, app); + } +} + +/// Calculate the dashboard regions used by both rendering and mouse hit-testing. +#[must_use] +pub(crate) fn dashboard_layout(area: Rect) -> DashboardLayout { let card_height = if area.height >= 30 { 7 } else if area.height >= 20 { 5 } else { - 0 // Skip cards on very small terminals + 0 }; - let mut constraints = vec![ - Constraint::Length(1), // header - ]; - + let mut constraints = vec![Constraint::Length(1)]; if card_height > 0 { - constraints.push(Constraint::Length(card_height)); // summary cards + constraints.push(Constraint::Length(card_height)); } + constraints.push(Constraint::Min(5)); + constraints.push(Constraint::Length(1)); - constraints.push(Constraint::Min(5)); // usage table - constraints.push(Constraint::Length(1)); // status bar + 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 + }; - let layout = Layout::vertical(constraints).split(area); + DashboardLayout { + header, + summary_cards, + usage_table: areas[index], + status_bar: areas[index + 1], + } +} - let mut idx = 0; +/// Translate a raw mouse event into a dashboard action. +#[must_use] +pub(crate) fn mouse_action(area: Rect, event: MouseEvent) -> Option { + let layout = dashboard_layout(area); - // Header - header::render(frame, layout[idx], app); - idx += 1; + match event.kind { + MouseEventKind::Down(MouseButton::Left) => layout + .summary_cards + .and_then(|cards| summary_cards::scope_at(cards, 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, + } +} - // Summary cards (if space) - if card_height > 0 { - summary_cards::render(frame, layout[idx], app); - idx += 1; +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, MouseAction}; + use crate::tui::app::Scope; + + fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } } - // Usage table - usage_table::render(frame, layout[idx], app); - idx += 1; + #[test] + fn dashboard_layout_uses_responsive_card_heights() { + let large = dashboard_layout(Rect::new(0, 0, 80, 30)); + assert_eq!(large.summary_cards, Some(Rect::new(0, 1, 80, 7))); + assert_eq!(large.usage_table, Rect::new(0, 8, 80, 21)); - // Status bar - status_bar::render(frame, layout[idx], app); + let medium = dashboard_layout(Rect::new(0, 0, 80, 20)); + assert_eq!(medium.summary_cards, Some(Rect::new(0, 1, 80, 5))); + assert_eq!(medium.usage_table, Rect::new(0, 6, 80, 13)); - // Overlays (rendered on top of everything) - if app.show_help { - help::render(frame); + let small = dashboard_layout(Rect::new(0, 0, 80, 19)); + assert_eq!(small.summary_cards, None); + assert_eq!(small.usage_table, Rect::new(0, 1, 80, 17)); } - if app.show_settings { - settings::render(frame, app); + + #[test] + fn left_click_on_card_selects_its_scope() { + let area = Rect::new(0, 0, 80, 30); + + assert_eq!( + mouse_action(area, 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, 19); + + assert_eq!( + mouse_action(area, 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, mouse(MouseEventKind::ScrollDown, 10, 10)), + Some(MouseAction::ScrollDown) + ); + assert_eq!( + mouse_action(area, 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, mouse(MouseEventKind::Moved, 10, 10)), + None + ); + assert_eq!( + mouse_action(area, mouse(MouseEventKind::Down(MouseButton::Right), 10, 2)), + None + ); } } diff --git a/src/tui/views/help.rs b/src/tui/views/help.rs index 91f918d..5c23aa3 100644 --- a/src/tui/views/help.rs +++ b/src/tui/views/help.rs @@ -41,6 +41,7 @@ pub fn render(frame: &mut Frame) { ("/", "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)"), diff --git a/src/tui/widgets/summary_cards.rs b/src/tui/widgets/summary_cards.rs index 2a6f32b..bb30316 100644 --- a/src/tui/widgets/summary_cards.rs +++ b/src/tui/widgets/summary_cards.rs @@ -15,23 +15,8 @@ use crate::tui::theme; /// - Token count (secondary) /// - Sparkline (trend) 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 (i, (scope, card_area)) in card_areas(area).into_iter().enumerate() { render_card( frame, card_area, @@ -42,6 +27,40 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { } } +/// Return the scope card rectangles in their rendered order. +#[must_use] +pub(crate) fn card_areas(area: Rect) -> [(Scope, Rect); 4] { + let [today, week, month, all_time] = Layout::horizontal([ + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 4), + ]) + .areas(area); + + [ + (Scope::Today, today), + (Scope::Week, week), + (Scope::Month, month), + (Scope::AllTime, all_time), + ] +} + +/// Return the scope card at a terminal coordinate, if any. +#[must_use] +pub(crate) fn scope_at(area: Rect, column: u16, row: u16) -> Option { + card_areas(area) + .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, @@ -146,3 +165,34 @@ fn render_card( frame.render_widget(sparkline, card_areas[3]); } } + +#[cfg(test)] +mod tests { + use ratatui::layout::Rect; + + use super::{card_areas, scope_at}; + use crate::tui::app::Scope; + + #[test] + fn splits_card_area_into_scope_order() { + let area = Rect::new(4, 2, 80, 7); + let cards = card_areas(area); + + 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); + + assert_eq!(scope_at(area, 0, 1), Some(Scope::Today)); + assert_eq!(scope_at(area, 19, 7), Some(Scope::Today)); + assert_eq!(scope_at(area, 20, 1), Some(Scope::Week)); + assert_eq!(scope_at(area, 79, 7), Some(Scope::AllTime)); + assert_eq!(scope_at(area, 80, 7), None); + assert_eq!(scope_at(area, 10, 8), None); + } +} From 06c816b9240d30dd40f1870b56871ad3ce91e7d1 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 25 Jul 2026 22:32:20 +0100 Subject: [PATCH 10/29] fix(cost): refresh routed model pricing --- src/cost.rs | 284 +++++++++++++++++++++++++++++++++++++++---------- src/display.rs | 3 +- src/tui/app.rs | 21 +--- 3 files changed, 234 insertions(+), 74 deletions(-) diff --git a/src/cost.rs b/src/cost.rs index ed2c591..30af72a 100644 --- a/src/cost.rs +++ b/src/cost.rs @@ -96,11 +96,6 @@ impl PricingEngine { } } - /// Returns `true` if the engine has any pricing data loaded. - pub fn is_empty(&self) -> bool { - self.models.is_empty() - } - /// Apply costs to all entries in-place, caching pricing lookups per model. pub fn apply_costs(&self, entries: &mut [Record]) { use std::collections::HashMap; @@ -147,64 +142,50 @@ impl PricingEngine { } } - /// Three-level model matching + /// Resolve model pricing, preferring an explicit Vertex route. fn find_pricing(&self, model: &str) -> Option<&ModelPricing> { - // Strip source-level provider prefix (e.g., "vertexai." from Vertex AI detection) - // so that the model name is clean for lookup against litellm pricing data. - let model = model.strip_prefix("vertexai.").unwrap_or(model); + let (route, plain_model) = split_pricing_route(model); + let normalized = normalize_model_name(plain_model); + let mut candidates = Vec::with_capacity(10); - // 1. Exact match - if let Some(p) = self.models.get(model) { - return Some(p); + if let Some(provider) = route { + push_model_candidates(&mut candidates, Some(provider), plain_model, &normalized); } + push_model_candidates(&mut candidates, None, plain_model, &normalized); - // 2. Normalized match (strip date suffix, lowercase) - let normalized = normalize_model_name(model); - if let Some(p) = self.models.get(&normalized) { - return Some(p); + for provider in ordered_provider_prefixes(route, &normalized) { + push_model_candidates(&mut candidates, Some(provider), plain_model, &normalized); } - // 3. Try with common provider prefixes - let prefixed_variants = [ - format!("anthropic/{model}"), - format!("anthropic/{normalized}"), - format!("openai/{model}"), - format!("openai/{normalized}"), - format!("google/{model}"), - format!("google/{normalized}"), - format!("vertex_ai/{model}"), - format!("vertex_ai/{normalized}"), - ]; - for variant in &prefixed_variants { - if let Some(p) = self.models.get(variant.as_str()) { - return Some(p); + for candidate in candidates { + if let Some(pricing) = self.models.get(&candidate) { + return Some(pricing); } } - // 4. Prefix match - longest match wins, requires word boundary - let mut best_match: Option<&ModelPricing> = None; - let mut best_len: usize = 0; - - for (key, pricing) in &self.models { - let plain_key = key.split('/').next_back().unwrap_or(key); - let norm_key = normalize_model_name(plain_key); - - // Only match if our model starts with the pricing key - // AND the match ends at a word boundary (delimiter or end of string) - if normalized.starts_with(&norm_key) && norm_key.len() > best_len { - let at_boundary = normalized.len() == norm_key.len() - || matches!( - normalized.as_bytes().get(norm_key.len()), - Some(b'-' | b'_' | b'.') - ); - if at_boundary { - best_match = Some(pricing); - best_len = norm_key.len(); - } - } - } - - best_match + // Fall back to a deterministic longest-prefix match. Provider rank + // breaks ties so an explicit route cannot select another provider's + // otherwise-equivalent entry. + let mut matches: Vec<(&str, &ModelPricing, usize, usize)> = self + .models + .iter() + .filter_map(|(key, pricing)| { + let (key_route, plain_key) = split_pricing_route(key); + let normalized_key = normalize_model_name(plain_key); + model_prefix_matches(&normalized, &normalized_key).then_some(( + key.as_str(), + pricing, + normalized_key.len(), + provider_rank(route, &normalized, key_route), + )) + }) + .collect(); + matches.sort_unstable_by(|a, b| { + b.2.cmp(&a.2) + .then_with(|| a.3.cmp(&b.3)) + .then_with(|| a.0.cmp(b.0)) + }); + matches.first().map(|(_, pricing, _, _)| *pricing) } fn cache_path() -> PathBuf { @@ -250,11 +231,94 @@ impl PricingEngine { } fn normalize_model_name(model: &str) -> String { - let s = model.to_lowercase(); + let s = strip_deployment_suffix(model).to_lowercase(); let stripped = crate::display::strip_date_suffix(&s); stripped.replace('.', "-") } +const PRICING_PROVIDERS: [&str; 4] = ["anthropic", "openai", "google", "vertex_ai"]; + +fn split_pricing_route(model: &str) -> (Option<&str>, &str) { + let route = + (model.starts_with("vertexai.") || model.starts_with("vertex_ai/")).then_some("vertex_ai"); + (route, crate::display::strip_routing_prefix(model)) +} + +fn strip_deployment_suffix(model: &str) -> &str { + model.split('@').next().unwrap_or(model) +} + +fn push_model_candidates( + candidates: &mut Vec, + provider: Option<&str>, + model: &str, + normalized: &str, +) { + for variant in [model, normalized] { + let candidate = provider.map_or_else( + || variant.to_string(), + |provider| format!("{provider}/{variant}"), + ); + if !candidates.contains(&candidate) { + candidates.push(candidate); + } + } +} + +fn ordered_provider_prefixes(route: Option<&str>, normalized_model: &str) -> Vec<&'static str> { + let inferred = infer_pricing_provider(normalized_model); + let mut providers = Vec::with_capacity(PRICING_PROVIDERS.len()); + + if let Some(provider) = inferred { + providers.push(provider); + } + for provider in PRICING_PROVIDERS { + if Some(provider) != route && !providers.contains(&provider) { + providers.push(provider); + } + } + + providers +} + +fn infer_pricing_provider(model: &str) -> Option<&'static str> { + if model.starts_with("claude-") { + Some("anthropic") + } else if model.starts_with("gpt-") + || model.starts_with("o1-") + || model.starts_with("o3-") + || model.starts_with("o4-") + { + Some("openai") + } else if model.starts_with("gemini-") || model.starts_with("gemma-") { + Some("google") + } else { + None + } +} + +fn model_prefix_matches(model: &str, prefix: &str) -> bool { + model.starts_with(prefix) + && (model.len() == prefix.len() + || matches!(model.as_bytes().get(prefix.len()), Some(b'-' | b'_' | b'.'))) +} + +fn provider_rank(route: Option<&str>, model: &str, candidate_route: Option<&str>) -> usize { + if let Some(expected) = route { + return match candidate_route { + Some(actual) if expected == actual => 0, + None => 1, + Some(_) => 2, + }; + } + + match candidate_route { + Some(actual) if Some(actual) == infer_pricing_provider(model) => 0, + None => 1, + Some(_) => 2, + } +} + #[cfg(test)] mod tests { use super::*; @@ -276,10 +340,21 @@ mod tests { } }"#; + const ROUTED_PRICING_JSON: &str = r#"{ + "claude-opus-5": { + "input_cost_per_token": 0.000005, + "output_cost_per_token": 0.000025 + }, + "vertex_ai/claude-opus-5": { + "input_cost_per_token": 0.000007, + "output_cost_per_token": 0.000035 + } + }"#; + #[test] fn test_parse_pricing_valid_json() { let engine = PricingEngine::parse_pricing(DUMMY_JSON).expect("Failed to parse dummy JSON"); - assert!(!engine.is_empty()); + assert!(!engine.models.is_empty()); assert_eq!(engine.models.len(), 3); let model_a = engine.models.get("model-a").expect("model-a missing"); @@ -350,6 +425,30 @@ mod tests { assert_eq!(p3.input_cost_per_token, Some(0.003)); } + #[test] + fn test_claude_wrapper_variants_resolve_equivalently() { + let engine = PricingEngine::parse_pricing(DUMMY_JSON).unwrap(); + let expected = engine + .find_pricing("claude-3-5-sonnet-20241022") + .expect("plain model should resolve"); + + for model in [ + "anthropic/claude-3-5-sonnet-20241022", + "vertexai.claude-3-5-sonnet-20241022", + "bedrock/anthropic.claude-3-5-sonnet-20241022", + "azure/anthropic.claude-3-5-sonnet-20241022", + "openai/claude-3-5-sonnet-20241022", + ] { + let resolved = engine + .find_pricing(model) + .unwrap_or_else(|| panic!("{model} should resolve")); + assert!( + std::ptr::eq(resolved, expected), + "{model} should resolve to the same pricing entry" + ); + } + } + #[test] fn test_find_pricing_longest_prefix() { let engine = PricingEngine::parse_pricing( @@ -373,6 +472,79 @@ mod tests { assert_eq!(p2.input_cost_per_token, Some(0.06)); } + #[test] + fn test_vertex_route_prefers_provider_specific_pricing() { + let engine = PricingEngine::parse_pricing(ROUTED_PRICING_JSON).unwrap(); + + let direct = engine + .find_pricing("claude-opus-5") + .expect("generic model should resolve"); + assert_eq!(direct.input_cost_per_token, Some(0.000005)); + + let routed = engine + .find_pricing("vertexai.claude-opus-5") + .expect("routed model should resolve"); + assert_eq!(routed.input_cost_per_token, Some(0.000007)); + } + + #[test] + fn test_vertex_routed_record_gets_provider_specific_cost() { + use chrono::Utc; + use std::borrow::Cow; + + let engine = PricingEngine::parse_pricing(ROUTED_PRICING_JSON).unwrap(); + let mut records = [Record { + timestamp: Utc::now(), + provider: Cow::Borrowed("test"), + model: Some("vertexai.claude-opus-5".to_string()), + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_read_tokens: 0, + cache_creation_tokens: 0, + thinking_tokens: 0, + cost_usd: Some(0.0), + message_id: None, + request_id: None, + session_id: None, + }]; + + engine.apply_costs(&mut records); + + assert_eq!(records[0].cost_usd, Some(42.0)); + } + + #[test] + fn test_vertex_route_normalizes_deployment_alias() { + let engine = PricingEngine::parse_pricing(ROUTED_PRICING_JSON).unwrap(); + + let routed = engine + .find_pricing("vertexai.claude-opus-5@default") + .expect("deployment alias should resolve"); + assert_eq!(routed.output_cost_per_token, Some(0.000035)); + } + + #[test] + fn test_vertex_route_deterministically_prefers_provider_fallback() { + let engine = PricingEngine::parse_pricing( + r#"{ + "anthropic/claude-opus-5-20260724": { + "input_cost_per_token": 0.000005 + }, + "vertex_ai/claude-opus-5@20260724": { + "input_cost_per_token": 0.000007 + } + }"#, + ) + .unwrap(); + + for _ in 0..10 { + let routed = engine + .find_pricing("vertexai.claude-opus-5@default") + .expect("provider fallback should resolve"); + assert_eq!(routed.input_cost_per_token, Some(0.000007)); + } + } + #[test] fn test_zero_cost_gets_repriced() { use chrono::Utc; diff --git a/src/display.rs b/src/display.rs index 0a2c898..7f35252 100644 --- a/src/display.rs +++ b/src/display.rs @@ -74,7 +74,8 @@ pub fn display_model(raw: &str) -> String { /// and dot-based prefixes (`vertexai.`, `anthropic.`). /// /// Returns a `&str` borrowed from the input — no allocation. -fn strip_routing_prefix(raw: &str) -> &str { +#[must_use] +pub(crate) fn strip_routing_prefix(raw: &str) -> &str { // Strip @... deployment suffix let raw = raw.split('@').next().unwrap_or(raw); // Strip slash-based prefixes (e.g., "bedrock/", "openai/") diff --git a/src/tui/app.rs b/src/tui/app.rs index 02112b3..6120dc0 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -284,24 +284,11 @@ impl App { all_time_base_start_week: None, all_time_base_models: Vec::new(), }; - // Load pricing engine once. Try offline first (fast path using - // cached pricing.json). If the cache doesn't exist yet (fresh - // install), fall back to a one-time online fetch if not in offline mode. + // Load pricing engine once. Online loading already uses a fresh cache + // without fetching, and refreshes stale data before falling back to it. if !config.no_cost { - match cost::PricingEngine::load(true) { - Ok(engine) => { - if engine.is_empty() && !offline { - match cost::PricingEngine::load(false) { - Ok(online_engine) => app.pricing = Some(online_engine), - Err(e) => { - app.last_warning = - Some((format!("Pricing unavailable: {e}"), Instant::now())); - } - } - } else { - app.pricing = Some(engine); - } - } + match cost::PricingEngine::load(offline) { + Ok(engine) => app.pricing = Some(engine), Err(e) => { app.last_warning = Some((format!("Pricing unavailable: {e}"), Instant::now())); } From 8d024c1f5bd61d248f776f2b022b377f2d9370a5 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 25 Jul 2026 22:39:35 +0100 Subject: [PATCH 11/29] feat: support local pricing overrides --- README.md | 17 +++++ src/config.rs | 6 +- src/cost.rs | 189 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/paths.rs | 7 ++ 4 files changed, 210 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bce2ecf..23629db 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,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 | diff --git a/src/config.rs b/src/config.rs index 370554d..f6c39d5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -312,11 +312,7 @@ impl Config { } 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) } } diff --git a/src/cost.rs b/src/cost.rs index ed2c591..69a6cbd 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(), }) @@ -211,6 +227,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()? @@ -249,6 +302,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 = model.to_lowercase(); let stripped = crate::display::strip_date_suffix(&s); @@ -258,6 +328,7 @@ fn normalize_model_name(model: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; const DUMMY_JSON: &str = r#"{ "model-a": { @@ -276,6 +347,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); + } + } + #[test] fn test_parse_pricing_valid_json() { let engine = PricingEngine::parse_pricing(DUMMY_JSON).expect("Failed to parse dummy JSON"); @@ -373,6 +461,99 @@ mod tests { assert_eq!(p2.input_cost_per_token, Some(0.06)); } + #[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/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") From b45eef7f09df4d2a686adf404b12728443681026 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 25 Jul 2026 22:45:27 +0100 Subject: [PATCH 12/29] feat: add local usage source --- src/display.rs | 2 + src/source/continue_dev.rs | 215 +++++++++++++++++++++++++++ src/source/mod.rs | 2 + tests/fixtures/continue_tokens.jsonl | 6 + tests/integration.rs | 19 +++ 5 files changed, 244 insertions(+) create mode 100644 src/source/continue_dev.rs create mode 100644 tests/fixtures/continue_tokens.jsonl diff --git a/src/display.rs b/src/display.rs index 0a2c898..980b87f 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(), @@ -202,6 +203,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/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/mod.rs b/src/source/mod.rs index c12b4b1..cc5f59c 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -3,6 +3,7 @@ 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 discover; @@ -86,6 +87,7 @@ impl SourceSet { 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()), 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(); From b4d737d51fba51c1100da87e8bdc401c5415efb9 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 25 Jul 2026 22:49:31 +0100 Subject: [PATCH 13/29] feat(tui): add usage visualizations --- src/tui/app.rs | 110 ++++++++++- src/tui/theme.rs | 15 ++ src/tui/views/dashboard.rs | 30 ++- src/tui/views/help.rs | 5 +- src/tui/widgets/heatmap.rs | 309 ++++++++++++++++++++++++++++++ src/tui/widgets/mod.rs | 2 + src/tui/widgets/spike_chart.rs | 330 +++++++++++++++++++++++++++++++++ src/tui/widgets/status_bar.rs | 36 ++-- 8 files changed, 821 insertions(+), 16 deletions(-) create mode 100644 src/tui/widgets/heatmap.rs create mode 100644 src/tui/widgets/spike_chart.rs diff --git a/src/tui/app.rs b/src/tui/app.rs index 02112b3..a18d057 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -13,6 +13,8 @@ use crate::{cache, cost, dedup, rollup}; use super::diff::{self, RowKey}; use super::event::Event; +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; @@ -22,6 +24,14 @@ const WARNING_DISPLAY_SECS: f64 = 5.0; // ── 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 { @@ -177,6 +187,12 @@ pub struct App { /// 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, /// Settings editor state. @@ -268,6 +284,9 @@ impl App { sort_order: SortOrder::CostDesc, highlight_map: HashMap::new(), last_warning: None, + fullscreen: FullscreenView::None, + heatmap_data: Vec::new(), + spike_data: None, show_settings: false, settings_state: SettingsState::new(config), dirty: true, @@ -331,7 +350,11 @@ impl App { // tick-based polling catches changes that `notify` may miss // (e.g. SQLite WAL writes on some platforms). self.poll_sources(); - 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 { @@ -351,6 +374,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) => { @@ -396,6 +420,42 @@ 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, + }; + } + match key.code { KeyCode::Char('q') | KeyCode::Esc => { if self.applied_filter.is_empty() { @@ -460,6 +520,16 @@ impl App { self.recompute_detail(); true } + KeyCode::Char('c') => { + self.recompute_heatmap(); + self.fullscreen = FullscreenView::Heatmap; + true + } + KeyCode::Char('v') => { + self.recompute_spike_chart(); + self.fullscreen = FullscreenView::SpikeChart; + true + } KeyCode::Char('j') | KeyCode::Down => { let max = self.detail_models.len().saturating_sub(1) as u16; self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); @@ -972,6 +1042,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 ─────────────────────────────────────────────────────────────── diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 0f23740..1ef39b3 100644 --- a/src/tui/theme.rs +++ b/src/tui/theme.rs @@ -196,6 +196,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..5015b50 100644 --- a/src/tui/views/dashboard.rs +++ b/src/tui/views/dashboard.rs @@ -2,10 +2,10 @@ use ratatui::layout::{Constraint, Layout}; use ratatui::widgets::Block; use ratatui::Frame; -use crate::tui::app::App; +use crate::tui::app::{App, FullscreenView}; 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}; /// Render the complete dashboard view. /// @@ -24,6 +24,32 @@ pub fn render(frame: &mut Frame, app: &App) { let bg = Block::default().style(theme::text()); frame.render_widget(bg, area); + 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 app.show_help { + help::render(frame); + } + if app.show_settings { + settings::render(frame, app); + } + return; + } + // Determine card height based on terminal height let card_height = if area.height >= 30 { 7 diff --git a/src/tui/views/help.rs b/src/tui/views/help.rs index 91f918d..d69aedd 100644 --- a/src/tui/views/help.rs +++ b/src/tui/views/help.rs @@ -37,13 +37,16 @@ pub fn render(frame: &mut Frame) { ("← / →", "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"), ("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/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 82e1964..6baaba2 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,29 @@ 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"), + ("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 { From 0220b7ad30cdbac5549651f12d68d95b181d8ffa Mon Sep 17 00:00:00 2001 From: mm65x Date: Fri, 7 Aug 2026 18:39:03 +0100 Subject: [PATCH 14/29] fix(tui): harden empty and error states (#16) * fix(tui): harden empty and error states * chore: satisfy current lint checks * fix(tui): keep cache failures responsive --------- Co-authored-by: mm65x --- Cargo.lock | 1 + Cargo.toml | 3 + src/cache.rs | 78 +++++++++++- src/tui/app.rs | 216 +++++++++++++++++++++++++--------- src/tui/mod.rs | 3 +- src/tui/terminal.rs | 65 ++++++++-- src/tui/widgets/status_bar.rs | 2 +- 7 files changed, 295 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42fc7e4..73bd19b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2298,6 +2298,7 @@ dependencies = [ "serde", "serde_json", "tabled", + "tempfile", "thiserror 2.0.18", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index b50ffc9..3782adb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,3 +52,6 @@ tokio = { version = "1", features = ["rt", "macros", "time", "sync"] } futures-lite = "2" notify = "7" notify-debouncer-mini = "0.5" + +[dev-dependencies] +tempfile = "3" diff --git a/src/cache.rs b/src/cache.rs index 056b400..903ccf9 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use chrono::{DateTime, NaiveDate}; -use rusqlite::{params, types::Value, Connection, Row}; +use rusqlite::{params, types::Value, Connection, OpenFlags, Row}; use std::borrow::Cow; @@ -12,6 +12,7 @@ use crate::paths; use crate::types::Record; const DB_FILENAME: &str = "usage.db"; +const DEFAULT_BUSY_TIMEOUT: Duration = Duration::from_secs(5); pub struct Cache { conn: Connection, @@ -19,15 +20,37 @@ pub struct Cache { impl Cache { pub fn open() -> crate::error::Result { - let db_path = Self::db_path(); + Self::open_with_busy_timeout(DEFAULT_BUSY_TIMEOUT) + } + + /// Open the writable cache with a caller-selected lock wait. + /// + /// Interactive callers use a short timeout so a cache lock cannot freeze + /// the UI, while batch commands retain the normal five-second timeout. + pub(crate) fn open_with_busy_timeout(busy_timeout: Duration) -> crate::error::Result { + Self::open_writable_path(&Self::db_path(), busy_timeout) + } + + /// Open the existing cache for reads without taking a write lock. + /// + /// This deliberately skips schema creation and the writable canary. The + /// writable open performed by the refresh path owns those responsibilities. + #[allow(dead_code)] + pub(crate) fn open_read_only_with_busy_timeout( + busy_timeout: Duration, + ) -> crate::error::Result { + Self::open_read_only_path(&Self::db_path(), busy_timeout) + } + + fn open_writable_path(db_path: &Path, busy_timeout: Duration) -> crate::error::Result { if let Some(parent) = db_path.parent() { fs::create_dir_all(parent)?; } - let conn = Connection::open(&db_path)?; + let conn = Connection::open(db_path)?; // Set busy timeout FIRST — before any other operation that could // encounter a lock held by another process (e.g. the watcher thread). - conn.busy_timeout(Duration::from_secs(5))?; + conn.busy_timeout(busy_timeout)?; // Configure PRAGMAs individually so each one is fully processed. // PRAGMA journal_mode returns a result row — use pragma_update @@ -56,6 +79,12 @@ impl Cache { Ok(cache) } + fn open_read_only_path(db_path: &Path, busy_timeout: Duration) -> crate::error::Result { + let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + conn.busy_timeout(busy_timeout)?; + Ok(Self { conn }) + } + fn db_path() -> PathBuf { paths::cache_dir().join(DB_FILENAME) } @@ -526,6 +555,7 @@ pub fn file_mtime_secs_for_db(path: &Path) -> Option { mod tests { use super::*; use std::collections::HashSet; + use std::time::Instant; /// Create an in-memory cache for testing. fn test_cache() -> Cache { @@ -728,4 +758,44 @@ mod tests { cache.init_schema().unwrap(); cache.init_schema().unwrap(); } + + #[test] + fn read_only_cache_remains_available_during_writer_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("usage.db"); + let writer = Cache::open_writable_path(&path, Duration::from_secs(1)).unwrap(); + writer.conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + + let started = Instant::now(); + let reader = Cache::open_read_only_path(&path, Duration::from_millis(50)).unwrap(); + let records = reader.load_all_entries().unwrap(); + + assert!(records.is_empty()); + assert!(started.elapsed() < Duration::from_secs(1)); + writer.conn.execute_batch("ROLLBACK").unwrap(); + } + + #[test] + fn writable_cache_respects_short_busy_timeout() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("usage.db"); + let writer = Cache::open_writable_path(&path, Duration::from_secs(1)).unwrap(); + writer.conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + + let started = Instant::now(); + let result = Cache::open_writable_path(&path, Duration::from_millis(50)); + + assert!(result.is_err()); + assert!(started.elapsed() < Duration::from_secs(1)); + writer.conn.execute_batch("ROLLBACK").unwrap(); + } + + #[test] + fn read_only_open_does_not_create_a_missing_cache() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("missing.db"); + + assert!(Cache::open_read_only_path(&path, Duration::from_millis(50)).is_err()); + assert!(!path.exists()); + } } diff --git a/src/tui/app.rs b/src/tui/app.rs index 02112b3..7f3c480 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::time::Instant; +use std::time::{Duration as StdDuration, Instant}; use crate::timestamp; use chrono::{Duration, NaiveDate, Utc}; @@ -20,6 +20,12 @@ 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; +/// Maximum time the interactive UI waits for a cache write lock. +/// +/// The background watcher keeps the normal cache timeout. The TUI must stay +/// responsive when another process temporarily owns the database writer lock. +const TUI_CACHE_BUSY_TIMEOUT: StdDuration = StdDuration::from_millis(100); + // ── View scope ──────────────────────────────────────────────────────────── /// Which time window the detail table shows. @@ -307,11 +313,14 @@ impl App { } } } - // Compute all-time base from historical records (before the - // in-memory window). This runs once at startup. - app.compute_all_time_base(); - // Initial data load: sync sources then read cache. - app.poll_sources(); + // Initial data load: sync sources before computing the historical + // base so a fresh cache includes old records on the first render. + if let Err(e) = app.poll_sources() { + app.set_warning(format!("Data refresh failed: {e}")); + } + if let Err(e) = app.compute_all_time_base() { + app.set_warning(format!("History unavailable: {e}")); + } app.reload_from_cache(); app } @@ -330,7 +339,9 @@ impl App { // The watcher thread also does this on file events, but // tick-based polling catches changes that `notify` may miss // (e.g. SQLite WAL writes on some platforms). - self.poll_sources(); + if let Err(e) = self.poll_sources() { + self.set_warning(format!("Data refresh failed: {e}")); + } self.dirty |= self.reload_from_cache(); // Expire old warnings if let Some((_, t)) = &self.last_warning { @@ -354,7 +365,7 @@ impl App { self.dirty } Event::Warning(msg) => { - self.last_warning = Some((msg.clone(), Instant::now())); + self.set_warning(msg.clone()); self.dirty = true; true } @@ -378,6 +389,11 @@ impl App { }) } + fn set_warning(&mut self, message: String) { + self.last_warning = Some((message, Instant::now())); + self.dirty = true; + } + #[allow(clippy::too_many_lines)] fn handle_key(&mut self, key: KeyEvent) -> bool { // Settings overlay takes priority @@ -449,7 +465,9 @@ impl App { } KeyCode::Char('g') => { self.group_by = self.group_by.next(); - self.compute_all_time_base(); + if let Err(e) = self.compute_all_time_base() { + self.set_warning(format!("History refresh failed: {e}")); + } self.prev_models.clear(); self.highlight_map.clear(); self.recompute_detail(); @@ -461,7 +479,8 @@ impl App { true } KeyCode::Char('j') | KeyCode::Down => { - let max = self.detail_models.len().saturating_sub(1) as u16; + 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); true } @@ -624,7 +643,9 @@ impl App { Some(("Saved!".to_string(), Instant::now())); // Recompute all-time base if sparkline metric changed if self.config.sparkline_metric != old_metric { - self.compute_all_time_base(); + if let Err(e) = self.compute_all_time_base() { + self.set_warning(format!("History refresh failed: {e}")); + } } } Err(e) => { @@ -641,21 +662,22 @@ impl App { /// 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. - fn compute_all_time_base(&mut self) { + fn compute_all_time_base(&mut self) -> crate::error::Result<()> { let cutoff = Scope::Month.since() - Duration::days(30); let Some(cutoff_pred) = cutoff.pred_opt() else { - return; + return Ok(()); }; - let Ok(cache) = cache::Cache::open() else { - return; - }; - let mut historical = cache - .load_entries_filtered(None, Some(cutoff_pred), &[]) - .unwrap_or_default(); + let cache = cache::Cache::open_read_only_with_busy_timeout(TUI_CACHE_BUSY_TIMEOUT)?; + let mut historical = cache.load_entries_filtered(None, Some(cutoff_pred), &[])?; if historical.is_empty() { - return; + self.all_time_base_cost = 0.0; + self.all_time_base_tokens = 0; + self.all_time_base_sparkline.clear(); + self.all_time_base_start_week = None; + self.all_time_base_models.clear(); + return Ok(()); } // Apply pricing to historical records. @@ -663,21 +685,27 @@ impl App { engine.apply_costs(&mut historical); } - self.all_time_base_cost = historical.iter().map(|r| r.cost_usd.unwrap_or(0.0)).sum(); - self.all_time_base_tokens = historical + let all_time_base_cost = historical.iter().map(|r| r.cost_usd.unwrap_or(0.0)).sum(); + let all_time_base_tokens = historical .iter() .map(super::super::types::Record::total_tokens) .sum(); // Build weekly sparkline from historical records. let use_cost = self.config.sparkline_metric == crate::config::SparklineMetric::Cost; - let (sparkline, start_week) = build_weekly_sparkline_data(&historical, use_cost); - self.all_time_base_sparkline = sparkline; - self.all_time_base_start_week = start_week; + let (all_time_base_sparkline, all_time_base_start_week) = + build_weekly_sparkline_data(&historical, use_cost); // Aggregate into model-level breakdown for the detail table. let summaries = rollup::aggregate_daily(&historical); - self.all_time_base_models = aggregate_summaries_to_models(&summaries, self.group_by); + let all_time_base_models = aggregate_summaries_to_models(&summaries, self.group_by); + + self.all_time_base_cost = all_time_base_cost; + self.all_time_base_tokens = all_time_base_tokens; + self.all_time_base_sparkline = all_time_base_sparkline; + self.all_time_base_start_week = all_time_base_start_week; + self.all_time_base_models = all_time_base_models; + Ok(()) } /// Poll source files for mtime changes, re-parse any that changed, @@ -687,35 +715,45 @@ impl App { /// It checks file modification times (including SQLite WAL siblings) /// against the mtimes stored in the cache. Only files with newer /// mtimes are re-parsed, so the cost is negligible when nothing changed. - fn poll_sources(&self) { - let Ok(mut cache) = cache::Cache::open() else { - return; - }; - let cached_mtimes = cache.cached_file_mtimes().unwrap_or_default(); - let providers = self.registry.available(); + fn poll_sources(&mut self) -> crate::error::Result<()> { + let mut cache = cache::Cache::open_with_busy_timeout(TUI_CACHE_BUSY_TIMEOUT)?; + let cached_mtimes = cache.cached_file_mtimes()?; // Collect parsed results with owned PathBufs. let mut parsed: Vec<(std::path::PathBuf, i64, Vec)> = Vec::new(); - - for provider in &providers { - for file in provider.discover_files() { - let mtime = cache::file_mtime_secs_for_db(&file).unwrap_or(0); - let file_key = file.display().to_string(); - if cached_mtimes.get(&file_key) == Some(&mtime) { - continue; - } - if let Ok(entries) = provider.parse_file(&file) { - // Don't apply pricing here — the cache stores raw source - // data. Pricing is applied at read time in - // load_records_from_cache(), matching the CLI path. - let entries = dedup::deduplicate(entries); - parsed.push((file, mtime, entries)); + let parse_warning = { + let providers = self.registry.available(); + let mut warning = None; + + for provider in &providers { + for file in provider.discover_files() { + let mtime = cache::file_mtime_secs_for_db(&file).unwrap_or(0); + let file_key = file.display().to_string(); + if cached_mtimes.get(&file_key) == Some(&mtime) { + continue; + } + match provider.parse_file(&file) { + Ok(entries) => { + // Don't apply pricing here — the cache stores raw source + // data. Pricing is applied at read time in + // load_records_from_cache(), matching the CLI path. + let entries = dedup::deduplicate(entries); + parsed.push((file, mtime, entries)); + } + Err(e) => { + warning = Some(format!("Failed to parse {}: {e}", file.display())); + } + } } } + warning + }; + if let Some(warning) = parse_warning { + self.set_warning(warning); } if parsed.is_empty() { - return; + return Ok(()); } // write_entries takes &[(&Path, i64, Vec)]. @@ -725,7 +763,8 @@ impl App { .map(|(p, m, e)| (p.as_path(), *m, std::mem::take(e))) .collect(); - let _ = cache.write_entries(&refs); + cache.write_entries(&refs)?; + Ok(()) } /// Re-read the cache and recompute all derived state. @@ -735,7 +774,13 @@ impl App { /// in-memory aggregations. File discovery and parsing are handled /// by the background watcher thread. fn reload_from_cache(&mut self) -> bool { - let records = load_records_from_cache(self.pricing.as_ref()); + let records = match load_records_from_cache(self.pricing.as_ref()) { + Ok(records) => records, + Err(e) => { + self.set_warning(format!("Usage cache unavailable: {e}")); + return false; + } + }; self.cached_records = records; // Snapshot cards before recomputing to detect card-only changes. @@ -811,6 +856,15 @@ impl App { }) } + #[must_use] + fn rendered_row_count(&self) -> usize { + table_row_count( + &self.detail_models, + &self.history_summaries, + self.show_history, + ) + } + fn recompute_cards(&mut self) { let today = Utc::now().date_naive(); let use_cost = self.config.sparkline_metric == crate::config::SparklineMetric::Cost; @@ -958,7 +1012,8 @@ impl App { }; // Sort dates descending so newest periods appear at the top - self.history_summaries.sort_by(|a, b| b.date.cmp(&a.date)); + self.history_summaries + .sort_by_key(|summary| std::cmp::Reverse(summary.date)); // Regroup and sort models within each period based on current settings for summary in &mut self.history_summaries { @@ -1007,17 +1062,15 @@ fn sort_models(models: &mut [ModelUsage], order: SortOrder) { // ── Data loading ────────────────────────────────────────────────────────── -fn load_records_from_cache(pricing: Option<&cost::PricingEngine>) -> Vec { - let Ok(c) = cache::Cache::open() else { - return Vec::new(); - }; +fn load_records_from_cache( + pricing: Option<&cost::PricingEngine>, +) -> crate::error::Result> { + let c = cache::Cache::open_read_only_with_busy_timeout(TUI_CACHE_BUSY_TIMEOUT)?; // Load everything — the TUI filters in memory for card summaries. // We load the last ~60 days to keep things bounded. let since = Scope::Month.since() - chrono::Duration::days(30); - let mut entries = c - .load_entries_filtered(Some(since), None, &[]) - .unwrap_or_default(); + let mut entries = c.load_entries_filtered(Some(since), None, &[])?; // Apply pricing from pre-loaded engine (no disk I/O here). if let Some(engine) = pricing { @@ -1026,5 +1079,52 @@ fn load_records_from_cache(pricing: Option<&cost::PricingEngine>) -> Vec // Dedup is handled inside load_entries_filtered. entries.sort_by_key(|e| e.timestamp); - entries + Ok(entries) +} + +fn table_row_count( + detail_models: &[ModelUsage], + history_summaries: &[PeriodSummary], + show_history: bool, +) -> usize { + let data_rows = if show_history && !history_summaries.is_empty() { + history_summaries + .iter() + .map(|summary| 1usize.saturating_add(summary.models.len())) + .sum() + } else { + detail_models.len() + }; + data_rows.saturating_add(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary(model_count: usize) -> PeriodSummary { + PeriodSummary { + date: NaiveDate::from_ymd_opt(2026, 1, 1).expect("valid date"), + label: "period".to_string(), + models: vec![ModelUsage::default(); model_count], + total_input: 0, + total_output: 0, + total_thinking: 0, + total_cost: 0.0, + total_requests: 0, + } + } + + #[test] + fn table_row_count_includes_history_headers_models_and_total() { + let history = vec![summary(2), summary(3)]; + assert_eq!(table_row_count(&[], &history, true), 8); + } + + #[test] + fn table_row_count_uses_detail_rows_outside_history_mode() { + let details = vec![ModelUsage::default(); 4]; + let history = vec![summary(2)]; + assert_eq!(table_row_count(&details, &history, false), 5); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index d31a5a7..99a9362 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -124,6 +124,5 @@ async fn run_async( } } - terminal::restore()?; - Ok(()) + Ok(terminal.finish()?) } diff --git a/src/tui/terminal.rs b/src/tui/terminal.rs index 0ef53e1..d2ff0d7 100644 --- a/src/tui/terminal.rs +++ b/src/tui/terminal.rs @@ -11,18 +11,64 @@ use ratatui::Terminal; pub type Tui = Terminal>; +/// Owns an initialised terminal session and restores it on every exit path. +pub struct TerminalSession { + terminal: Tui, + active: bool, +} + +impl TerminalSession { + /// Restore the terminal and consume the session. + pub fn finish(mut self) -> io::Result<()> { + restore()?; + self.active = false; + Ok(()) + } +} + +impl std::ops::Deref for TerminalSession { + type Target = Tui; + + fn deref(&self) -> &Self::Target { + &self.terminal + } +} + +impl std::ops::DerefMut for TerminalSession { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.terminal + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + if self.active { + let _ = restore(); + } + } +} + /// Initialise the terminal: raw mode, alternate screen, mouse capture. -/// Returns the `Terminal` handle. +/// Returns a session that restores the terminal when dropped. /// /// # Errors /// /// Returns an error if terminal initialisation fails. -pub fn init() -> io::Result { +pub fn init() -> io::Result { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + if let Err(error) = execute!(stdout, EnterAlternateScreen, EnableMouseCapture) { + let _ = restore(); + return Err(error); + } let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; + let terminal = match Terminal::new(backend) { + Ok(terminal) => terminal, + Err(error) => { + let _ = restore(); + return Err(error); + } + }; // Install a panic hook that restores the terminal before printing the // panic message — otherwise the user is left with a broken terminal. @@ -32,7 +78,10 @@ pub fn init() -> io::Result { original_hook(info); })); - Ok(terminal) + Ok(TerminalSession { + terminal, + active: true, + }) } /// Restore the terminal to its original state. @@ -41,7 +90,7 @@ pub fn init() -> io::Result { /// /// Returns an error if terminal restoration fails. pub fn restore() -> io::Result<()> { - disable_raw_mode()?; - execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; - Ok(()) + let screen_result = execute!(io::stdout(), DisableMouseCapture, LeaveAlternateScreen); + let raw_result = disable_raw_mode(); + screen_result.and(raw_result) } diff --git a/src/tui/widgets/status_bar.rs b/src/tui/widgets/status_bar.rs index 82e1964..543bb72 100644 --- a/src/tui/widgets/status_bar.rs +++ b/src/tui/widgets/status_bar.rs @@ -43,7 +43,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { if !app.applied_filter.is_empty() { spans.push(Span::styled("filter:", theme::status_key())); spans.push(Span::styled( - format!("{} ", &app.applied_filter), + format!("{} ", app.applied_filter), Style::default().fg(theme::YELLOW).bg(theme::SURFACE), )); spans.push(Span::styled(" │ ", theme::status_bar())); From 5fe8cecf0fdfd96a613d7db737533102fb39c18a Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 8 Aug 2026 11:34:57 +0100 Subject: [PATCH 15/29] feat(tui): add persistent metric visibility --- src/config.rs | 18 +++++ src/tui/settings_state.rs | 51 +++++++++++- src/tui/widgets/usage_table.rs | 139 +++++++++++++++++++++++++++------ 3 files changed, 181 insertions(+), 27 deletions(-) diff --git a/src/config.rs b/src/config.rs index 370554d..7794481 100644 --- a/src/config.rs +++ b/src/config.rs @@ -175,6 +175,7 @@ pub struct ColumnConfig { pub output: bool, pub cache_write: bool, pub cache_read: bool, + pub requests: bool, pub total_tokens: bool, pub cost: bool, } @@ -214,6 +215,7 @@ impl Default for ColumnConfig { output: true, cache_write: true, cache_read: true, + requests: true, total_tokens: true, cost: true, } @@ -351,6 +353,7 @@ mod tests { assert!(config.columns.output); assert!(config.columns.cache_write); assert!(config.columns.cache_read); + assert!(config.columns.requests); assert!(config.columns.total_tokens); assert!(config.columns.cost); @@ -359,6 +362,21 @@ mod tests { assert!(config.budget.monthly.is_none()); } + #[test] + fn existing_column_config_defaults_new_visibility_fields() { + let config: Config = toml::from_str( + r#" + [columns] + cost = false + "#, + ) + .expect("existing config should deserialize"); + + assert!(config.columns.requests); + assert!(config.columns.total_tokens); + assert!(!config.columns.cost); + } + #[test] fn test_default_command_enum() { assert_eq!(DefaultCommand::Daily.next(), DefaultCommand::Weekly); diff --git a/src/tui/settings_state.rs b/src/tui/settings_state.rs index d6a2728..0023602 100644 --- a/src/tui/settings_state.rs +++ b/src/tui/settings_state.rs @@ -23,11 +23,14 @@ pub(crate) enum SettingField { ColClient, ColInput, ColOutput, + ColRequests, + ColTotalTokens, + ColCost, } impl SettingField { /// Total number of settings fields. - pub const COUNT: usize = 16; + pub const COUNT: usize = 19; /// All fields in display order. pub const ALL: [Self; Self::COUNT] = [ @@ -47,6 +50,9 @@ impl SettingField { Self::ColClient, Self::ColInput, Self::ColOutput, + Self::ColRequests, + Self::ColTotalTokens, + Self::ColCost, ]; /// Display label for this field. @@ -69,6 +75,9 @@ impl SettingField { Self::ColClient => "Client", Self::ColInput => "Input Tokens", Self::ColOutput => "Output Tokens", + Self::ColRequests => "Requests", + Self::ColTotalTokens => "Total Tokens", + Self::ColCost => "Cost", } } @@ -95,6 +104,9 @@ impl SettingField { | Self::ColClient | Self::ColInput | Self::ColOutput + | Self::ColRequests + | Self::ColTotalTokens + | Self::ColCost ) } @@ -143,6 +155,9 @@ impl SettingField { Self::ColClient => bool_display(config.columns.client), Self::ColInput => bool_display(config.columns.input), Self::ColOutput => bool_display(config.columns.output), + Self::ColRequests => bool_display(config.columns.requests), + Self::ColTotalTokens => bool_display(config.columns.total_tokens), + Self::ColCost => bool_display(config.columns.cost), } } @@ -155,6 +170,9 @@ impl SettingField { 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::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, _ => {} } } @@ -307,3 +325,34 @@ impl SettingsState { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metric_visibility_is_independent_from_cost_calculation() { + let mut config = Config::default(); + + SettingField::ColCost.toggle_bool(&mut config); + assert!(!config.columns.cost); + assert!(!config.no_cost); + + SettingField::NoCost.toggle_bool(&mut config); + assert!(config.no_cost); + assert!(!config.columns.cost); + } + + #[test] + fn metric_fields_toggle_their_persisted_columns() { + let mut config = Config::default(); + + SettingField::ColRequests.toggle_bool(&mut config); + SettingField::ColTotalTokens.toggle_bool(&mut config); + SettingField::ColCost.toggle_bool(&mut config); + + assert!(!config.columns.requests); + assert!(!config.columns.total_tokens); + assert!(!config.columns.cost); + } +} diff --git a/src/tui/widgets/usage_table.rs b/src/tui/widgets/usage_table.rs index 5c64cae..2409aad 100644 --- a/src/tui/widgets/usage_table.rs +++ b/src/tui/widgets/usage_table.rs @@ -229,6 +229,8 @@ struct ColumnSet { show_input: bool, show_output: bool, show_requests: bool, + show_total: bool, + show_cost: bool, } impl ColumnSet { @@ -238,9 +240,11 @@ impl ColumnSet { Self { show_api: self.show_api && cfg.api_provider, show_client: self.show_client && cfg.client, - show_requests: self.show_requests, + show_requests: self.show_requests && cfg.requests, show_input: self.show_input && cfg.input, show_output: self.show_output && cfg.output, + show_total: self.show_total && cfg.total_tokens, + show_cost: self.show_cost && cfg.cost, } } @@ -261,8 +265,12 @@ impl ColumnSet { if self.show_output { h.push("Output".to_string()); } - h.push("Total".to_string()); - h.push("Cost".to_string()); + if self.show_total { + h.push("Total".to_string()); + } + if self.show_cost { + h.push("Cost".to_string()); + } h } @@ -283,8 +291,12 @@ impl ColumnSet { if self.show_output { w.push(Constraint::Length(8)); } - w.push(Constraint::Length(8)); // Total - w.push(Constraint::Length(10)); // Cost + if self.show_total { + w.push(Constraint::Length(8)); + } + if self.show_cost { + w.push(Constraint::Length(10)); + } w } @@ -354,23 +366,27 @@ impl ColumnSet { cells.push(Cell::from(Span::styled(s, style))); } - let total_s = format_tokens_short(total); - let normal_total = if use_color { - base_style.fg(theme::tokens_color(total)) - } else { - base_style - }; - let total_style = apply_highlight(normal_total, highlight_intensity); - cells.push(Cell::from(Span::styled(total_s, total_style))); + if self.show_total { + let total_s = format_tokens_short(total); + let normal_total = if use_color { + base_style.fg(theme::tokens_color(total)) + } else { + base_style + }; + let total_style = apply_highlight(normal_total, highlight_intensity); + cells.push(Cell::from(Span::styled(total_s, total_style))); + } - let cost_s = format_cost(cost); - let normal_cost = if use_color { - base_style.fg(theme::cost_color(cost)) - } else { - base_style - }; - let cost_style = apply_highlight(normal_cost, highlight_intensity); - cells.push(Cell::from(Span::styled(cost_s, cost_style))); + if self.show_cost { + let cost_s = format_cost(cost); + let normal_cost = if use_color { + base_style.fg(theme::cost_color(cost)) + } else { + base_style + }; + let cost_style = apply_highlight(normal_cost, highlight_intensity); + cells.push(Cell::from(Span::styled(cost_s, cost_style))); + } cells } @@ -401,11 +417,15 @@ impl ColumnSet { if self.show_output { cells.push(Cell::from(Span::styled("", style))); } - cells.push(Cell::from(Span::styled( - format_tokens_short(total_tokens), - style, - ))); - cells.push(Cell::from(Span::styled(format_cost(total_cost), style))); + if self.show_total { + cells.push(Cell::from(Span::styled( + format_tokens_short(total_tokens), + style, + ))); + } + if self.show_cost { + cells.push(Cell::from(Span::styled(format_cost(total_cost), style))); + } cells } } @@ -419,6 +439,8 @@ fn choose_columns(width: usize) -> ColumnSet { show_requests: true, show_input: true, show_output: true, + show_total: true, + show_cost: true, } } else if width >= 70 { ColumnSet { @@ -427,6 +449,8 @@ fn choose_columns(width: usize) -> ColumnSet { show_requests: true, show_input: true, show_output: true, + show_total: true, + show_cost: true, } } else if width >= 56 { ColumnSet { @@ -435,6 +459,8 @@ fn choose_columns(width: usize) -> ColumnSet { show_requests: true, show_input: true, show_output: true, + show_total: true, + show_cost: true, } } else if width >= 42 { ColumnSet { @@ -443,6 +469,8 @@ fn choose_columns(width: usize) -> ColumnSet { show_requests: true, show_input: false, show_output: false, + show_total: true, + show_cost: true, } } else { ColumnSet { @@ -451,6 +479,8 @@ fn choose_columns(width: usize) -> ColumnSet { show_requests: false, show_input: false, show_output: false, + show_total: true, + show_cost: true, } } } @@ -465,3 +495,60 @@ fn apply_highlight(normal: Style, intensity: f64) -> Style { let normal_fg = normal.fg.unwrap_or(theme::FG); theme::highlight_cell(intensity, normal_fg) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn masks_metric_columns_with_persisted_visibility() { + let mut config = ColumnConfig::default(); + config.requests = false; + config.total_tokens = false; + config.cost = false; + + let columns = choose_columns(100).mask(&config); + + assert_eq!( + columns.headers(), + ["Model", "API", "Client", "Input", "Output"] + ); + assert_eq!(columns.headers().len(), columns.widths().len()); + } + + #[test] + fn narrow_layout_still_applies_visibility_choices() { + let mut config = ColumnConfig::default(); + config.cost = false; + + let columns = choose_columns(30).mask(&config); + + assert_eq!(columns.headers(), ["Model", "Total"]); + assert_eq!(columns.headers().len(), columns.widths().len()); + } + + #[test] + fn row_and_total_cells_match_visible_headers() { + let mut config = ColumnConfig::default(); + config.total_tokens = false; + let columns = choose_columns(100).mask(&config); + + let row = columns.build_row( + "model", + "api", + "client", + 1, + 2, + 3, + 5, + 0.1, + Style::default(), + false, + 0.0, + ); + let total = columns.build_total_row(1, 5, 0.1); + + assert_eq!(row.len(), columns.headers().len()); + assert_eq!(total.len(), columns.headers().len()); + } +} From 01e3a9e848eaed8e8d112eec96a02f748318cdc4 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 8 Aug 2026 15:14:37 +0100 Subject: [PATCH 16/29] chore(release): prepare v0.2.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 73bd19b..f88bbe8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2282,7 +2282,7 @@ dependencies = [ [[package]] name = "tokemon" -version = "0.2.5" +version = "0.2.6" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 3782adb..b716706 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tokemon" -version = "0.2.5" +version = "0.2.6" edition = "2021" description = "Unified LLM token usage tracking across all providers" license = "MIT" From f87cdf41e2e4d9201912f7ddcbda4c1fe4905da9 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 8 Aug 2026 20:28:40 +0100 Subject: [PATCH 17/29] feat(tui): add hover feedback --- src/tui/app.rs | 24 +++++++++- src/tui/event.rs | 10 +++-- src/tui/theme.rs | 3 ++ src/tui/views/dashboard.rs | 17 ++++++- src/tui/widgets/summary_cards.rs | 35 ++++++++++----- src/tui/widgets/usage_table.rs | 77 +++++++++++++++++++++++++++++++- 6 files changed, 148 insertions(+), 18 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 267c89f..e0cf7e7 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -78,6 +78,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 { @@ -184,6 +191,8 @@ 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. @@ -278,6 +287,7 @@ impl App { applied_filter: String::new(), sort_order: SortOrder::CostDesc, highlight_map: HashMap::new(), + hovered: None, last_warning: None, show_settings: false, settings_state: SettingsState::new(config), @@ -376,15 +386,25 @@ impl App { fn handle_mouse(&mut self, mouse: MouseEvent, terminal_area: Rect) -> bool { if self.show_settings || self.show_help || self.filter_active { - return false; + return self.set_hover(None); } - match dashboard::mouse_action(terminal_area, mouse) { + let hover_changed = self.set_hover(dashboard::hover_target(terminal_area, self, mouse)); + let action_changed = match dashboard::mouse_action(terminal_area, 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 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). diff --git a/src/tui/event.rs b/src/tui/event.rs index c5ac6d6..e6df361 100644 --- a/src/tui/event.rs +++ b/src/tui/event.rs @@ -11,7 +11,7 @@ use tokio::sync::mpsc; pub enum Event { /// A key was pressed. Key(crossterm::event::KeyEvent), - /// A mouse button was pressed or the wheel was scrolled. + /// 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)] @@ -124,6 +124,7 @@ fn map_crossterm_event(event: &CrosstermEvent) -> Option { MouseEventKind::Down(MouseButton::Left) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + | MouseEventKind::Moved ) => { Some(Event::Mouse(*mouse)) @@ -169,7 +170,7 @@ mod tests { } #[test] - fn ignores_mouse_motion() { + fn routes_mouse_motion() { let mouse = MouseEvent { kind: MouseEventKind::Moved, column: 12, @@ -177,6 +178,9 @@ mod tests { modifiers: KeyModifiers::NONE, }; - assert!(map_crossterm_event(&CrosstermEvent::Mouse(mouse)).is_none()); + assert!(matches!( + map_crossterm_event(&CrosstermEvent::Mouse(mouse)), + Some(Event::Mouse(event)) if event == mouse + )); } } diff --git a/src/tui/theme.rs b/src/tui/theme.rs index 0f23740..b85c5a1 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); diff --git a/src/tui/views/dashboard.rs b/src/tui/views/dashboard.rs index 1fe180c..87ec5f7 100644 --- a/src/tui/views/dashboard.rs +++ b/src/tui/views/dashboard.rs @@ -3,7 +3,7 @@ use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::widgets::Block; use ratatui::Frame; -use crate::tui::app::App; +use crate::tui::app::{App, HoverTarget}; use crate::tui::theme; use crate::tui::views::{help, settings}; use crate::tui::widgets::{header, status_bar, summary_cards, usage_table}; @@ -123,6 +123,21 @@ pub(crate) fn mouse_action(area: Rect, event: MouseEvent) -> Option } } +/// Resolve pointer movement to a rendered interactive region. +#[must_use] +pub(crate) fn hover_target(area: Rect, app: &App, event: MouseEvent) -> Option { + let layout = dashboard_layout(area); + + layout + .summary_cards + .and_then(|cards| summary_cards::scope_at(cards, 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) diff --git a/src/tui/widgets/summary_cards.rs b/src/tui/widgets/summary_cards.rs index bb30316..24338f6 100644 --- a/src/tui/widgets/summary_cards.rs +++ b/src/tui/widgets/summary_cards.rs @@ -4,7 +4,7 @@ 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}; use crate::tui::theme; /// Render the four summary cards: Today, This Week, This Month, All Time. @@ -22,6 +22,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { card_area, &app.cards[i], scope == app.scope, + matches!(app.hovered.as_ref(), Some(HoverTarget::Card(hovered)) if *hovered == scope), show_sparklines, ); } @@ -66,11 +67,19 @@ fn render_card( area: Rect, card: &crate::tui::app::CardData, active: bool, + hovered: bool, show_sparklines: bool, ) { + let surface = if hovered { + theme::SURFACE_HOVER + } else { + theme::SURFACE + }; // Card block with border let border_style = if active { theme::border().fg(theme::ACCENT) + } else if hovered { + theme::border().fg(theme::ACCENT_DIM) } else { theme::border() }; @@ -78,7 +87,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); @@ -112,9 +121,11 @@ fn render_card( // 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, @@ -125,20 +136,24 @@ 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]); } @@ -160,7 +175,7 @@ fn render_card( } else { theme::ACCENT_DIM }) - .bg(theme::SURFACE), + .bg(surface), ); frame.render_widget(sparkline, card_areas[3]); } 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(); From e2b21408682ba1a70f541e703d2f221a5f114f1a Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 8 Aug 2026 21:31:36 +0100 Subject: [PATCH 18/29] style: format merged tui changes --- src/tui/app.rs | 13 +++++++------ src/tui/views/dashboard.rs | 10 ++++++++-- src/tui/widgets/summary_cards.rs | 5 ++++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 0c34d74..bf840ec 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -450,12 +450,13 @@ impl App { } 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, - }; + 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 } diff --git a/src/tui/views/dashboard.rs b/src/tui/views/dashboard.rs index 5ec265e..ba13425 100644 --- a/src/tui/views/dashboard.rs +++ b/src/tui/views/dashboard.rs @@ -132,7 +132,9 @@ pub(crate) fn hover_target(area: Rect, app: &App, event: MouseEvent) -> Option Date: Sat, 8 Aug 2026 21:35:13 +0100 Subject: [PATCH 19/29] fix: complete summary card merge --- src/tui/views/dashboard.rs | 2 +- src/tui/widgets/summary_cards.rs | 25 +++---------------------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/src/tui/views/dashboard.rs b/src/tui/views/dashboard.rs index ba13425..a70a0e3 100644 --- a/src/tui/views/dashboard.rs +++ b/src/tui/views/dashboard.rs @@ -155,7 +155,7 @@ mod tests { use ratatui::layout::Rect; use super::{dashboard_layout, mouse_action, summary_card_height, MouseAction}; - use crate::tui::app::Scope; + use crate::tui::app::{Scope, SummaryCardVisibility}; fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent { MouseEvent { diff --git a/src/tui/widgets/summary_cards.rs b/src/tui/widgets/summary_cards.rs index 3dccf41..046e41d 100644 --- a/src/tui/widgets/summary_cards.rs +++ b/src/tui/widgets/summary_cards.rs @@ -22,31 +22,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { } } -/// Return all four scope card rectangles in their default rendered order. -#[must_use] -pub(crate) fn card_areas(area: Rect) -> [(Scope, Rect); 4] { - let [today, week, month, all_time] = Layout::horizontal([ - Constraint::Ratio(1, 4), - Constraint::Ratio(1, 4), - Constraint::Ratio(1, 4), - Constraint::Ratio(1, 4), - ]) - .areas(area); - - [ - (Scope::Today, today), - (Scope::Week, week), - (Scope::Month, month), - (Scope::AllTime, all_time), - ] -} - 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 = visible_scopes.len(); + let count = visibility.visible_count(); if count == 0 { return Vec::new(); } @@ -188,13 +169,13 @@ fn render_card( mod tests { use ratatui::layout::Rect; - use super::{card_areas, scope_at}; + 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 = card_areas(area); + 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))); From bb1a1995d10f224a27f108046c07351d1806e5bd Mon Sep 17 00:00:00 2001 From: mm65x Date: Sat, 8 Aug 2026 22:53:46 +0100 Subject: [PATCH 20/29] Fix import formatting --- src/tui/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index 442f67f..c2432bf 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -14,9 +14,9 @@ use crate::{cache, cost, dedup, rollup}; use super::diff::{self, RowKey}; use super::event::Event; +use super::views::dashboard::{self, MouseAction}; use super::widgets::heatmap::{self, HeatmapDay}; use super::widgets::spike_chart::{self, SpikeSeries}; -use super::views::dashboard::{self, MouseAction}; /// Duration (in seconds) for the per-cell highlight fade animation. const HIGHLIGHT_DURATION_SECS: f64 = 1.5; From e777a0b58a17c68e51ece7f50eab69d028af4591 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 00:01:06 +0100 Subject: [PATCH 21/29] feat: add interactive configuration editor --- src/cli.rs | 2 + src/config.rs | 73 +++++++++++++- src/main.rs | 1 + src/tui/app.rs | 74 +++++++++----- src/tui/mod.rs | 15 ++- src/tui/settings_state.rs | 205 ++++++++++++++++++++++++++++++-------- src/tui/views/settings.rs | 38 +++++-- 7 files changed, 334 insertions(+), 74 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 193cd91..bbc1144 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -120,6 +120,8 @@ pub enum Commands { Discover, /// Generate default config file at ~/.config/tokemon/config.toml Init, + /// Open the interactive configuration editor + Config, /// Show per-session cost breakdown Sessions { /// Show top N sessions by cost diff --git a/src/config.rs b/src/config.rs index c23e1c8..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,13 +304,35 @@ 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(()) } @@ -318,6 +341,25 @@ impl Config { } } +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, + } +} + #[cfg(test)] mod tests { use super::*; @@ -418,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/main.rs b/src/main.rs index 6bed560..73debc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ fn main() -> anyhow::Result<()> { Ok(()) } Commands::Init => cmd_init(), + 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), diff --git a/src/tui/app.rs b/src/tui/app.rs index c2432bf..b5559cb 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -274,6 +274,8 @@ pub struct App { 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. @@ -369,6 +371,7 @@ impl App { heatmap_data: Vec::new(), spike_data: None, show_settings: false, + config_only: false, settings_state: SettingsState::new(config), dirty: true, config: config.clone(), @@ -718,6 +721,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 @@ -725,8 +739,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(); @@ -737,7 +754,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; } @@ -754,6 +773,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 => { @@ -803,33 +825,37 @@ 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 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. diff --git a/src/tui/mod.rs b/src/tui/mod.rs index b079578..988fb86 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -55,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( @@ -63,10 +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), 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/views/settings.rs b/src/tui/views/settings.rs index 449b33e..c1b040c 100644 --- a/src/tui/views/settings.rs +++ b/src/tui/views/settings.rs @@ -3,6 +3,7 @@ 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; @@ -14,7 +15,7 @@ pub fn render(frame: &mut Frame, app: &App) { 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_width = area.width.min(80); let popup_height = area.height.min(32); let popup_area = centered_rect(popup_width, popup_height, area); @@ -23,10 +24,12 @@ pub fn render(frame: &mut Frame, app: &App) { 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() @@ -46,6 +49,22 @@ pub fn render(frame: &mut Frame, app: &App) { 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 if let Some(header) = field.section_header() { @@ -165,11 +184,18 @@ pub fn render(frame: &mut Frame, app: &App) { 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), From d6c3d66c599ee8e32b4a953a8d3a54294d201642 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 00:10:18 +0100 Subject: [PATCH 22/29] feat: add declarative custom JSONL sources --- src/cli.rs | 15 ++ src/main.rs | 37 +++- src/source/custom.rs | 422 +++++++++++++++++++++++++++++++++++++++++++ src/source/mod.rs | 47 ++--- 4 files changed, 498 insertions(+), 23 deletions(-) create mode 100644 src/source/custom.rs diff --git a/src/cli.rs b/src/cli.rs index 193cd91..9b5bb57 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 { @@ -120,6 +121,11 @@ pub enum Commands { Discover, /// Generate default config file at ~/.config/tokemon/config.toml Init, + /// Manage declarative local usage sources + Source { + #[command(subcommand)] + command: SourceCommands, + }, /// Show per-session cost breakdown Sessions { /// Show top N sessions by cost @@ -145,3 +151,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/main.rs b/src/main.rs index 6bed560..e6e0e3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,10 +33,10 @@ 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 source::{Source, SourceSet}; use types::{Report, SessionReport}; fn main() -> anyhow::Result<()> { @@ -50,6 +50,7 @@ fn main() -> anyhow::Result<()> { Ok(()) } Commands::Init => cmd_init(), + Commands::Source { command } => cmd_source(command), Commands::Statusline => cmd_statusline(&cli, &config), Commands::Budget => cmd_budget(&cli, &config), Commands::Sessions { top } => cmd_sessions(&cli, &config, *top), @@ -102,6 +103,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. 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 cc5f59c..78225bb 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -6,6 +6,7 @@ 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; @@ -76,27 +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(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()), - ], - } + 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> { From a96ca6ae6b83899ac32579a9440d8bf2a7c67320 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 10:32:31 +0100 Subject: [PATCH 23/29] feat: add versioned JSON status output --- README.md | 2 ++ src/cli.rs | 2 ++ src/main.rs | 66 +++++++++++++++++++++++++++++++++++++++++++- src/render/json.rs | 9 +++++- src/render/mod.rs | 2 +- src/types.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 23629db..3d530af 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 diff --git a/src/cli.rs b/src/cli.rs index df944e9..99ab53f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -113,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 diff --git a/src/main.rs b/src/main.rs index 0e621c6..f561aaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,7 +37,7 @@ use cli::{Cli, Commands, Frequency, SourceCommands}; use config::Config; use pipeline::load_and_price; use source::{Source, SourceSet}; -use types::{Report, SessionReport}; +use types::{Report, SessionReport, StatusCapabilities, StatusReport, StatusScope}; fn main() -> anyhow::Result<()> { let cli = Cli::parse(); @@ -45,6 +45,7 @@ fn main() -> anyhow::Result<()> { match &cli.command { Commands::Report => cmd_report(&cli, &config), + Commands::Status => cmd_status(&cli, &config), Commands::Discover => { cmd_discover(); Ok(()) @@ -245,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); @@ -390,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/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/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)); + } } From 464cc5b00275ac2b2c6fcb297dd005ee21a2f97e Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 14:46:05 +0100 Subject: [PATCH 24/29] feat: parse structured telemetry usage records --- src/source/copilot.rs | 253 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 226 insertions(+), 27 deletions(-) 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")); } } From 9721c3e9f388f6a7eb7a5627c8d500b1814d0da9 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 23:04:59 +0100 Subject: [PATCH 25/29] docs: align supported source table --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d530af..7ebd046 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Prices are in USD per token. Entries are merged by exact model key after the bas | 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 | From bfe2257e53392b03c2b3ed83588c5b605c1b30b3 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 23:16:26 +0100 Subject: [PATCH 26/29] feat: add settings editor mouse input --- src/tui/app.rs | 92 +++++++++++--- src/tui/views/settings.rs | 253 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 18 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index b5559cb..ad515bc 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -15,6 +15,7 @@ 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}; @@ -472,7 +473,10 @@ impl App { } fn handle_mouse(&mut self, mouse: MouseEvent, terminal_area: Rect) -> bool { - if self.show_settings || self.show_help || self.filter_active { + if self.show_settings { + return self.handle_settings_mouse(mouse, terminal_area); + } + if self.show_help || self.filter_active { return self.set_hover(None); } @@ -487,6 +491,59 @@ impl App { hover_changed || action_changed } + fn handle_settings_mouse(&mut self, mouse: MouseEvent, terminal_area: Rect) -> bool { + 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; @@ -786,23 +843,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() { @@ -832,6 +873,21 @@ impl App { } } + 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() { diff --git a/src/tui/views/settings.rs b/src/tui/views/settings.rs index c1b040c..b23fb4c 100644 --- a/src/tui/views/settings.rs +++ b/src/tui/views/settings.rs @@ -1,3 +1,4 @@ +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}; @@ -8,6 +9,27 @@ 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 { + 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) { @@ -236,6 +258,153 @@ pub fn render(frame: &mut Frame, app: &App) { frame.render_widget(footer_paragraph, footer_area); } +/// 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 { + 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 { let [popup_area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) @@ -246,3 +415,87 @@ 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 { + 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 { + 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 { + 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) + ); + } +} From 398fd9eda72d55db79803ad0ae8c533120d46393 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 23:27:36 +0100 Subject: [PATCH 27/29] refactor: share settings layout geometry --- src/tui/views/settings.rs | 49 +++++++++++++-------------------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/src/tui/views/settings.rs b/src/tui/views/settings.rs index b23fb4c..961f8ea 100644 --- a/src/tui/views/settings.rs +++ b/src/tui/views/settings.rs @@ -25,6 +25,8 @@ pub(crate) enum MouseAction { #[derive(Debug, Clone, Copy)] struct SettingsLayout { + popup: Rect, + inner: Rect, content: Rect, footer: Rect, scroll_offset: usize, @@ -36,11 +38,8 @@ 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(80); - 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); @@ -64,12 +63,11 @@ 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() { @@ -99,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 @@ -177,30 +173,9 @@ 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(); @@ -255,7 +230,7 @@ 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 @@ -329,6 +304,8 @@ fn settings_layout(area: Rect, app: &App) -> SettingsLayout { }; SettingsLayout { + popup: popup_area, + inner, content, footer, scroll_offset, @@ -435,6 +412,8 @@ mod tests { #[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], @@ -449,6 +428,8 @@ mod tests { 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], @@ -461,6 +442,8 @@ mod tests { #[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, From 1b859d01541a2ba7d7cc2736c9ef0133a33b5f39 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 23:30:45 +0100 Subject: [PATCH 28/29] fix: preserve settings edits during mouse input --- src/tui/app.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/tui/app.rs b/src/tui/app.rs index ad515bc..6f32e92 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -492,6 +492,12 @@ impl App { } 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 { From 7165c94a30e5ae85441a0ee8414dbbb0c5e51d71 Mon Sep 17 00:00:00 2001 From: mm65x Date: Sun, 9 Aug 2026 23:46:16 +0100 Subject: [PATCH 29/29] chore(release): prepare v0.2.7 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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"