From 6f7368e7f3fa7d2f3ee0ef606b46b18f9da0b8f3 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Thu, 18 Sep 2025 08:27:45 -0600 Subject: [PATCH 1/3] Update Codex support It was changed in https://github.com/openai/codex/pull/3380 --- Cargo.lock | 14 + Cargo.toml | 3 + README.md | 3 - src/analyzers/codex_cli.rs | 434 +++++++++++-------------------- src/analyzers/tests/codex_cli.rs | 117 +++++++++ src/analyzers/tests/mod.rs | 1 + src/models.rs | 3 +- src/tui.rs | 18 +- src/utils.rs | 16 -- 9 files changed, 302 insertions(+), 307 deletions(-) create mode 100644 src/analyzers/tests/codex_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 93efbb5..bbe0bd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1768,6 +1768,7 @@ dependencies = [ "serde_bytes", "sha2", "simd-json", + "tempfile", "tokio", "toml", ] @@ -1849,6 +1850,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix 1.0.8", + "windows-sys 0.60.2", +] + [[package]] name = "thiserror" version = "2.0.12" diff --git a/Cargo.toml b/Cargo.toml index f4ef0f5..eeef622 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,6 @@ features = ["derive"] version = "0.12.22" default-features = false features = ["rustls-tls"] + +[dev-dependencies] +tempfile = "3.0" diff --git a/README.md b/README.md index 9bd2e0b..0d92085 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,6 @@ Splitrail is a **fast, cross-platform, real-time Gemini CLI / Claude Code / Code **Download the binary for your platform on the [Releases](https://github.com/Piebald-AI/splitrail/releases) page.** -> [!WARNING] -> While support for both Codex **is** implemented, Codex currently does not output enough information to its recorded chat files. A PR is open on Codex, however: https://github.com/openai/codex/pull/1583. React with :+1: on it to encourage it to be merged! - Also check out our developer-first agentic AI experience, [Piebald](https://piebald.ai/). ## Screenshots diff --git a/src/analyzers/codex_cli.rs b/src/analyzers/codex_cli.rs index bfdfade..f34dd0c 100644 --- a/src/analyzers/codex_cli.rs +++ b/src/analyzers/codex_cli.rs @@ -2,17 +2,14 @@ use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; use crate::analyzer::{Analyzer, DataSource}; use crate::models::calculate_total_cost; -use crate::types::{ - AgenticCodingToolStats, Application, ConversationMessage, FileCategory, MessageRole, Stats, -}; -use crate::utils::{deserialize_optional_utc_timestamp, deserialize_utc_timestamp, hash_text}; +use crate::types::{AgenticCodingToolStats, Application, ConversationMessage, MessageRole, Stats}; +use crate::utils::{deserialize_utc_timestamp, hash_text}; pub struct CodexCliAnalyzer; @@ -106,7 +103,7 @@ impl Analyzer for CodexCliAnalyzer { } } -// CODEX CLI JSONL FILES SCHEMA +// CODEX CLI JSONL FILES SCHEMA - NEW WRAPPER FORMAT #[derive(Debug, Clone, Serialize, Deserialize)] struct CodexCliTokenUsage { @@ -123,77 +120,76 @@ struct CodexCliTokenUsage { } #[derive(Debug, Clone, Serialize, Deserialize)] -struct CodexCliMessage { - #[serde(rename = "type")] - message_type: String, - role: Option, - content: Option, - token_usage: Option, +struct CodexCliTokenCountInfo { + total_token_usage: Option, + last_token_usage: Option, + model_context_window: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexCliGitInfo { + commit_hash: Option, + branch: Option, + repository_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexCliSessionMeta { + id: String, #[serde(deserialize_with = "deserialize_utc_timestamp")] timestamp: DateTime, + cwd: Option, + originator: Option, + cli_version: Option, + instructions: Option, + git: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct CodexCliShellAction { +struct CodexCliMessage { #[serde(rename = "type")] - action_type: String, - command: Vec, - timeout_ms: Option, - working_directory: Option, - env: Option>, + message_type: String, + role: Option, + content: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct CodexCliShellCall { - #[serde(rename = "type")] - call_type: String, - id: Option, - call_id: Option, - status: Option, - action: Option, - #[serde(deserialize_with = "deserialize_optional_utc_timestamp")] - timestamp: Option>, +struct CodexCliTurnContext { + cwd: Option, + approval_policy: Option, + model: Option, + summary: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct CodexCliFunctionOutput { +struct CodexCliEventMsg { #[serde(rename = "type")] - output_type: String, - call_id: Option, - output: Option, - #[serde(deserialize_with = "deserialize_optional_utc_timestamp")] - timestamp: Option>, + event_type: String, + message: Option, + text: Option, + info: Option, } +// Wrapper structure for all entries #[derive(Debug, Clone, Serialize, Deserialize)] -struct CodexCliSessionHeader { - id: String, +struct CodexCliWrapper { #[serde(deserialize_with = "deserialize_utc_timestamp")] timestamp: DateTime, - instructions: Option, - model: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -enum CodexCliEntry { - SessionHeader(CodexCliSessionHeader), - Message(CodexCliMessage), - ShellCall(CodexCliShellCall), - FunctionOutput(CodexCliFunctionOutput), - // Fallback for unknown entries - Unknown(simd_json::OwnedValue), + #[serde(rename = "type")] + entry_type: String, + payload: simd_json::OwnedValue, } -fn parse_codex_cli_jsonl_file(file_path: &Path) -> Result> { +pub(crate) fn parse_codex_cli_jsonl_file(file_path: &Path) -> Result> { let mut entries = Vec::new(); let file_path_str = file_path.to_string_lossy(); let file = File::open(file_path)?; - let reader = BufReader::with_capacity(64 * 1024, file); + let mut session_model: Option = None; - let mut pending_shell_calls: HashMap = HashMap::new(); + let mut current_token_usage: Option = None; + let mut _turn_context: Option = None; for line_result in reader.lines() { let line = match line_result { @@ -205,121 +201,121 @@ fn parse_codex_cli_jsonl_file(file_path: &Path) -> Result(&mut line.clone().into_bytes()) { - Ok(entry) => entry, + let wrapper = match simd_json::from_slice::(&mut line.clone().into_bytes()) + { + Ok(wrapper) => wrapper, Err(_) => continue, }; - match entry { - CodexCliEntry::SessionHeader(header) => { - session_model = header.model; + match wrapper.entry_type.as_str() { + "session_meta" => { + // Try to parse the payload as session metadata + let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?; + if let Ok(_session_meta) = + simd_json::from_slice::(&mut payload_bytes) + { + // Extract model from session if available - in new format it might be in turn_context + session_model = None; // Will get from turn_context + } } - CodexCliEntry::Message(message) => { - if message.message_type == "message" - && let Some(role) = &message.role + "turn_context" => { + let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?; + if let Ok(context) = + simd_json::from_slice::(&mut payload_bytes) { - match role.as_str() { - "user" => { - entries.push(ConversationMessage { - date: message.timestamp, - global_hash: hash_text(&format!( - "{}_{}", - file_path_str, - message.timestamp.to_rfc3339() - )), - local_hash: None, - conversation_hash: hash_text(&file_path_str), - application: Application::CodexCli, - project_hash: "".to_string(), - model: None, - stats: Stats::default(), - role: MessageRole::User, - }); - } - "assistant" => { - let model_name = session_model - .clone() - .unwrap_or_else(|| "unknown".to_string()); - - if let Some(usage) = message.token_usage { - let total_output_tokens = - usage.output_tokens + usage.reasoning_output_tokens; - - let timestamp = message.timestamp; - let stats = Stats { - input_tokens: usage.input_tokens, - output_tokens: total_output_tokens, - cache_creation_tokens: 0, - cache_read_tokens: 0, - cached_tokens: usage.cached_input_tokens, - cost: calculate_cost_from_tokens(&usage, &model_name), - tool_calls: 0, - ..Default::default() - }; - - entries.push(ConversationMessage { - application: Application::CodexCli, - model: Some(model_name), - global_hash: hash_text(&format!( - "{}_{}", - file_path_str, - timestamp.to_rfc3339() - )), - local_hash: None, - conversation_hash: hash_text(&file_path_str), - date: timestamp, - project_hash: "".to_string(), - stats, - role: MessageRole::Assistant, - }); + session_model = context.model.clone(); + _turn_context = Some(context); + } + } + "response_item" => { + let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?; + if let Ok(message) = simd_json::from_slice::(&mut payload_bytes) { + if message.message_type == "message" { + if let Some(role) = &message.role { + match role.as_str() { + "user" => { + entries.push(ConversationMessage { + date: wrapper.timestamp, + global_hash: hash_text(&format!( + "{}_{}", + file_path_str, + wrapper.timestamp.to_rfc3339() + )), + local_hash: None, + conversation_hash: hash_text(&file_path_str), + application: Application::CodexCli, + project_hash: "".to_string(), + model: None, + stats: Stats::default(), + role: MessageRole::User, + }); + } + "assistant" => { + let model_name = session_model + .clone() + .unwrap_or_else(|| "unknown".to_string()); + + // Use token usage if we have it + let stats = if let Some(usage) = ¤t_token_usage { + let total_output_tokens = + usage.output_tokens + usage.reasoning_output_tokens; + + // Subtract cached input tokens from total input tokens + // since Codex input_tokens is a superset that includes cached tokens + let actual_input_tokens = usage.input_tokens.saturating_sub(usage.cached_input_tokens); + + Stats { + input_tokens: actual_input_tokens, + output_tokens: total_output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: 0, + cached_tokens: usage.cached_input_tokens, + cost: calculate_cost_from_tokens(usage, &model_name), + tool_calls: 0, + ..Default::default() + } + } else { + Stats::default() + }; + + entries.push(ConversationMessage { + application: Application::CodexCli, + model: Some(model_name), + global_hash: hash_text(&format!( + "{}_{}", + file_path_str, + wrapper.timestamp.to_rfc3339() + )), + local_hash: None, + conversation_hash: hash_text(&file_path_str), + date: wrapper.timestamp, + project_hash: "".to_string(), + stats, + role: MessageRole::Assistant, + }); + + // Clear token usage after using it + current_token_usage = None; + } + _ => {} } } - _ => {} } } } - CodexCliEntry::ShellCall(shell_call) => { - if shell_call.call_type == "local_shell_call" { - if let Some(call_id) = &shell_call.call_id { - pending_shell_calls.insert(call_id.clone(), shell_call.clone()); + "event_msg" => { + let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?; + if let Ok(event) = simd_json::from_slice::(&mut payload_bytes) { + if event.event_type == "token_count" { + if let Some(info) = event.info { + // Use last_token_usage if available, otherwise total_token_usage + current_token_usage = info.last_token_usage.or(info.total_token_usage); + } } - - // Count as a tool call and create a user message to represent the shell call - let stats = if let Some(action) = &shell_call.action { - parse_shell_command_for_file_operations(&action.command) - } else { - Stats::default() - }; - - let timestamp = shell_call.timestamp.unwrap_or_else(|| { - DateTime::parse_from_rfc3339("1970-01-01T00:00:00Z") - .unwrap() - .into() - }); - - entries.push(ConversationMessage { - global_hash: hash_text(&format!( - "{}_{}", - file_path_str, - timestamp.to_rfc3339() - )), - local_hash: None, - conversation_hash: hash_text(&file_path_str), - date: timestamp, - application: Application::CodexCli, - project_hash: "".to_string(), - model: None, - stats, - role: MessageRole::User, - }); } } - CodexCliEntry::FunctionOutput(_) => { - // We can track function outputs if needed, but for now just skip - // Could be used to track command success/failure status - } - CodexCliEntry::Unknown(_) => { - // Skip unknown entries + _ => { + // Skip other types for now } } } @@ -327,138 +323,16 @@ fn parse_codex_cli_jsonl_file(file_path: &Path) -> Result Stats { - let mut stats = Stats::default(); - - if command.is_empty() { - return stats; - } - - // Join the command for easier parsing - let full_command = command.join(" "); - - // Count basic shell operations - stats.terminal_commands += 1; - - // Parse the actual command (usually after "bash -lc") - let actual_command = if command.len() >= 3 && command[0] == "bash" && command[1] == "-lc" { - &command[2] - } else { - &full_command - }; - - // Detect file operations based on command patterns - if actual_command.contains("rg ") || actual_command.contains("grep ") { - stats.file_content_searches += 1; - } - - if actual_command.contains("--files") || actual_command.contains("find ") { - stats.file_searches += 1; - } - - // Detect file reads - if actual_command.contains("cat ") - || actual_command.contains("head ") - || actual_command.contains("tail ") - || actual_command.contains("less ") - || actual_command.contains("more ") - || actual_command.contains("sed -n") - { - stats.files_read += 1; - - // Try to extract file paths and categorize - extract_file_paths_from_command(actual_command, &mut stats); - - // Estimate lines read (rough approximation) - if actual_command.contains("sed -n") { - // Try to extract line numbers from sed command - if let Some(lines) = extract_line_count_from_sed(actual_command) { - stats.lines_read += lines; - stats.bytes_read += lines * 80; // Rough estimate - } - } else { - stats.lines_read += 100; // Default estimate - stats.bytes_read += 8000; // Default estimate - } - } - - // Detect file writes - if actual_command.contains(" > ") - || actual_command.contains(" >> ") - || actual_command.contains("tee ") - || actual_command.contains("echo ") - { - stats.files_edited += 1; - stats.lines_edited += 10; // Rough estimate - stats.bytes_edited += 800; // Rough estimate - } - - // Detect file edits - if actual_command.contains("sed -i") || actual_command.contains("awk") { - stats.files_edited += 1; - stats.lines_edited += 5; // Rough estimate - stats.bytes_edited += 400; // Rough estimate - } - - stats -} - -fn extract_file_paths_from_command(command: &str, stats: &mut Stats) { - // This is a rough implementation - it tries to find file paths in the command - let words: Vec<&str> = command.split_whitespace().collect(); - - for word in words { - // Skip flags and common non-file words - if word.starts_with('-') || word.starts_with('/') && word.len() < 3 { - continue; - } - - // Look for file extensions - if let Some(dot_pos) = word.rfind('.') { - let ext = &word[dot_pos + 1..]; - if ext.len() <= 5 && ext.chars().all(|c| c.is_alphabetic()) { - let category = FileCategory::from_extension(ext); - // Estimate lines per file operation - let estimated_lines = 50; // Conservative estimate - match category { - FileCategory::SourceCode => stats.code_lines += estimated_lines, - FileCategory::Documentation => stats.docs_lines += estimated_lines, - FileCategory::Data => stats.data_lines += estimated_lines, - FileCategory::Media => stats.media_lines += estimated_lines, - FileCategory::Config => stats.config_lines += estimated_lines, - FileCategory::Other => stats.other_lines += estimated_lines, - } - } - } - } -} - -fn extract_line_count_from_sed(command: &str) -> Option { - // Try to extract line numbers from sed -n 'X,Yp' commands - if let Some(start) = command.find("sed -n '") { - let after_quote = &command[start + 8..]; - if let Some(end) = after_quote.find('\'') { - let range = &after_quote[..end]; - if let Some(range_part) = range.strip_suffix('p') - && let Some(comma) = range_part.find(',') - { - let start_str = &range_part[..comma]; - let end_str = &range_part[comma + 1..]; - if let (Ok(start), Ok(end)) = (start_str.parse::(), end_str.parse::()) { - return Some(end - start + 1); - } - } - } - } - None -} - fn calculate_cost_from_tokens(usage: &CodexCliTokenUsage, model_name: &str) -> f64 { let total_output_tokens = usage.output_tokens + usage.reasoning_output_tokens; + // Subtract cached input tokens from total input tokens + // since Codex input_tokens is a superset that includes cached tokens + let actual_input_tokens = usage.input_tokens.saturating_sub(usage.cached_input_tokens); + calculate_total_cost( model_name, - usage.input_tokens, + actual_input_tokens, total_output_tokens, 0, // Codex CLI doesn't have separate cache creation tokens usage.cached_input_tokens, diff --git a/src/analyzers/tests/codex_cli.rs b/src/analyzers/tests/codex_cli.rs new file mode 100644 index 0000000..759d66c --- /dev/null +++ b/src/analyzers/tests/codex_cli.rs @@ -0,0 +1,117 @@ +use std::io::Write; +use tempfile::NamedTempFile; + +use crate::analyzer::Analyzer; +use crate::analyzers::codex_cli::*; + +#[test] +fn test_parse_codex_cli_new_wrapper_format() { + // Create a temporary file with the new wrapper format + let mut temp_file = NamedTempFile::new().unwrap(); + + // Session metadata + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:16:27.465Z","type":"session_meta","payload":{{"id":"243232f1-a7ab-44e6-b2c3-045b673746ea","timestamp":"2025-09-18T00:16:27.461Z","cwd":"/home/test","originator":"codex_cli_rs","cli_version":"0.38.0","instructions":null,"git":{{"commit_hash":"e4b91cc29da68a6fee1edaf44ce50a64bfbdce63","branch":"main","repository_url":"https://github.com/test/repo.git"}}}}}}"# + ).unwrap(); + + // Turn context with model info + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:16:36.676Z","type":"turn_context","payload":{{"cwd":"/home/test","approval_policy":"on-request","model":"gpt-5-codex","summary":"auto"}}}}"# + ).unwrap(); + + // User message + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:16:36.675Z","type":"response_item","payload":{{"type":"message","role":"user","content":[{{"type":"input_text","text":"Hey"}}]}}}}"# + ).unwrap(); + + // Token count event + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:16:38.851Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":2629,"cached_input_tokens":2560,"output_tokens":14,"reasoning_output_tokens":0,"total_tokens":2643}},"last_token_usage":{{"input_tokens":2629,"cached_input_tokens":2560,"output_tokens":14,"reasoning_output_tokens":0,"total_tokens":2643}},"model_context_window":272000}}}}}}"# + ).unwrap(); + + // Assistant message + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:16:38.852Z","type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"Hey! How can I help today?"}}]}}}}"# + ).unwrap(); + + let result = parse_codex_cli_jsonl_file(temp_file.path()).unwrap(); + + // Should have parsed user and assistant messages + assert!(result.len() >= 2); + + // Find the assistant message + let assistant_msg = result + .iter() + .find(|msg| matches!(msg.role, crate::types::MessageRole::Assistant)) + .unwrap(); + + assert_eq!(assistant_msg.model, Some("gpt-5-codex".to_string())); + assert_eq!(assistant_msg.stats.input_tokens, 69); // 2629 - 2560 (cached tokens subtracted) + assert_eq!(assistant_msg.stats.output_tokens, 14); // output_tokens + reasoning_output_tokens (0) + assert_eq!(assistant_msg.stats.cached_tokens, 2560); +} + +#[test] +fn test_parse_codex_cli_wrapper_format_no_tokens() { + // Test wrapper format without token information + let mut temp_file = NamedTempFile::new().unwrap(); + + // Turn context with model info + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:16:36.676Z","type":"turn_context","payload":{{"model":"gpt-4o"}}}}"# + ).unwrap(); + + // Assistant message without preceding token count + writeln!( + temp_file, + r#"{{"timestamp":"2025-09-18T00:16:38.852Z","type":"response_item","payload":{{"type":"message","role":"assistant","content":[{{"type":"output_text","text":"Hello!"}}]}}}}"# + ).unwrap(); + + let result = parse_codex_cli_jsonl_file(temp_file.path()).unwrap(); + + // Should have parsed the assistant message + assert!(result.len() >= 1); + + let assistant_msg = result + .iter() + .find(|msg| matches!(msg.role, crate::types::MessageRole::Assistant)) + .unwrap(); + + assert_eq!(assistant_msg.model, Some("gpt-4o".to_string())); + // Should have default stats when no token info is available + assert_eq!(assistant_msg.stats.input_tokens, 0); + assert_eq!(assistant_msg.stats.output_tokens, 0); +} + +#[test] +fn test_codex_availability() { + let analyzer = CodexCliAnalyzer::new(); + + println!( + "Codex CLI data patterns: {:?}", + analyzer.get_data_glob_patterns() + ); + + let sources = analyzer.discover_data_sources(); + println!("Discovered sources: {:?}", sources); + + let is_available = analyzer.is_available(); + println!("Is available: {}", is_available); + + // For debugging - let's check if the home directory exists + if let Some(home_dir) = std::env::home_dir() { + let codex_path = home_dir.join(".codex/sessions"); + println!("Codex path exists: {}", codex_path.exists()); + if codex_path.exists() { + println!("Contents: {:?}", std::fs::read_dir(&codex_path)); + } + } + + // Don't assert - just print for debugging +} diff --git a/src/analyzers/tests/mod.rs b/src/analyzers/tests/mod.rs index 7504f17..41821b7 100644 --- a/src/analyzers/tests/mod.rs +++ b/src/analyzers/tests/mod.rs @@ -1 +1,2 @@ mod claude_code; +mod codex_cli; diff --git a/src/models.rs b/src/models.rs index cd95865..5d26550 100644 --- a/src/models.rs +++ b/src/models.rs @@ -8,7 +8,7 @@ pub struct PricingTier { pub max_tokens: Option, /// Input cost per 1M tokens pub input_per_1m: f64, - /// Output cost per 1M tokens + /// Output cost per 1M tokens pub output_per_1m: f64, } @@ -518,6 +518,7 @@ static MODEL_ALIASES: phf::Map<&'static str, &'static str> = phf_map! { "gpt-4-turbo" => "gpt-4-turbo", "gpt-4-turbo-2024-04-09" => "gpt-4-turbo", "gpt-5" => "gpt-5", + "gpt-5-codex" => "gpt-5", "gpt-5-2025-08-07" => "gpt-5", "gpt-5-mini" => "gpt-5-mini", "gpt-5-mini-2025-08-07" => "gpt-5-mini", diff --git a/src/tui.rs b/src/tui.rs index e11a7f5..68abcd1 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -481,7 +481,7 @@ fn draw_daily_stats_table( Cell::new(Text::from("Outp Tks").right_aligned()), Cell::new(Text::from("Convs").right_aligned()), Cell::new(Text::from("Tools").right_aligned()), - Cell::new(Text::from("Lines").right_aligned()), + // Cell::new(Text::from("Lines").right_aligned()), Cell::new("Models"), ]) .style(Style::default().add_modifier(Modifier::BOLD)) @@ -683,7 +683,7 @@ fn draw_daily_stats_table( } .right_aligned(); - let lines_cell = if is_empty_row { + let _lines_cell = if is_empty_row { Line::from(Span::styled( lines_summary, Style::default().add_modifier(Modifier::DIM), @@ -722,7 +722,7 @@ fn draw_daily_stats_table( output_cell, conv_cell, tool_cell, - lines_cell, + // lines_cell, models_cell, ]); @@ -777,10 +777,12 @@ fn draw_daily_stats_table( "──────", Style::default().add_modifier(Modifier::DIM), )), + /* Line::from(Span::styled( "───────────────────────", Style::default().add_modifier(Modifier::DIM), )), + */ Line::from(Span::styled( "─".repeat(all_models_text.len().max(18)), Style::default().add_modifier(Modifier::DIM), @@ -789,17 +791,17 @@ fn draw_daily_stats_table( rows.push(separator_row); // Add totals row - let total_lines_r = stats + let _total_lines_r = stats .daily_stats .values() .map(|s| s.stats.lines_read) .sum::(); - let total_lines_e = stats + let _total_lines_e = stats .daily_stats .values() .map(|s| s.stats.lines_edited) .sum::(); - let total_lines_a = stats + let _total_lines_a = stats .daily_stats .values() .map(|s| s.stats.lines_added) @@ -857,6 +859,7 @@ fn draw_daily_stats_table( .add_modifier(Modifier::BOLD), )) .right_aligned(), + /* Line::from(Span::styled( format!( "{}/{}/{}", @@ -869,6 +872,7 @@ fn draw_daily_stats_table( .add_modifier(Modifier::BOLD), )) .right_aligned(), + */ Line::from(Span::styled( all_models_text, Style::default().add_modifier(Modifier::DIM), @@ -891,7 +895,7 @@ fn draw_daily_stats_table( Constraint::Length(9), // Output Constraint::Length(6), // Convs Constraint::Length(6), // Tools - Constraint::Length(23), // Lines + // Constraint::Length(23), // Lines Constraint::Min(10), // Models ], ) diff --git a/src/utils.rs b/src/utils.rs index e999635..c4d1c1e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -259,19 +259,3 @@ where .map(|dt| dt.into()) .map_err(serde::de::Error::custom) } - -/// Custom serde deserializer for optional RFC3339 timestamp strings to Option> -pub fn deserialize_optional_utc_timestamp<'de, D>( - deserializer: D, -) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - let opt = Option::::deserialize(deserializer)?; - match opt { - Some(s) => DateTime::parse_from_rfc3339(&s) - .map(|dt| Some(dt.into())) - .map_err(serde::de::Error::custom), - None => Ok(None), - } -} From 811feea02d02dfd5387dbc4cad6f154042c25a4a Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Thu, 18 Sep 2025 08:29:48 -0600 Subject: [PATCH 2/3] Format and lint --- src/analyzers/codex_cli.rs | 151 ++++++++++++++++++------------------- src/tui.rs | 2 +- 2 files changed, 76 insertions(+), 77 deletions(-) diff --git a/src/analyzers/codex_cli.rs b/src/analyzers/codex_cli.rs index f34dd0c..ad296c2 100644 --- a/src/analyzers/codex_cli.rs +++ b/src/analyzers/codex_cli.rs @@ -229,89 +229,88 @@ pub(crate) fn parse_codex_cli_jsonl_file(file_path: &Path) -> Result { let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?; - if let Ok(message) = simd_json::from_slice::(&mut payload_bytes) { - if message.message_type == "message" { - if let Some(role) = &message.role { - match role.as_str() { - "user" => { - entries.push(ConversationMessage { - date: wrapper.timestamp, - global_hash: hash_text(&format!( - "{}_{}", - file_path_str, - wrapper.timestamp.to_rfc3339() - )), - local_hash: None, - conversation_hash: hash_text(&file_path_str), - application: Application::CodexCli, - project_hash: "".to_string(), - model: None, - stats: Stats::default(), - role: MessageRole::User, - }); - } - "assistant" => { - let model_name = session_model - .clone() - .unwrap_or_else(|| "unknown".to_string()); - - // Use token usage if we have it - let stats = if let Some(usage) = ¤t_token_usage { - let total_output_tokens = - usage.output_tokens + usage.reasoning_output_tokens; - - // Subtract cached input tokens from total input tokens - // since Codex input_tokens is a superset that includes cached tokens - let actual_input_tokens = usage.input_tokens.saturating_sub(usage.cached_input_tokens); - - Stats { - input_tokens: actual_input_tokens, - output_tokens: total_output_tokens, - cache_creation_tokens: 0, - cache_read_tokens: 0, - cached_tokens: usage.cached_input_tokens, - cost: calculate_cost_from_tokens(usage, &model_name), - tool_calls: 0, - ..Default::default() - } - } else { - Stats::default() - }; - - entries.push(ConversationMessage { - application: Application::CodexCli, - model: Some(model_name), - global_hash: hash_text(&format!( - "{}_{}", - file_path_str, - wrapper.timestamp.to_rfc3339() - )), - local_hash: None, - conversation_hash: hash_text(&file_path_str), - date: wrapper.timestamp, - project_hash: "".to_string(), - stats, - role: MessageRole::Assistant, - }); - - // Clear token usage after using it - current_token_usage = None; + if let Ok(message) = simd_json::from_slice::(&mut payload_bytes) + && message.message_type == "message" + && let Some(role) = &message.role + { + match role.as_str() { + "user" => { + entries.push(ConversationMessage { + date: wrapper.timestamp, + global_hash: hash_text(&format!( + "{}_{}", + file_path_str, + wrapper.timestamp.to_rfc3339() + )), + local_hash: None, + conversation_hash: hash_text(&file_path_str), + application: Application::CodexCli, + project_hash: "".to_string(), + model: None, + stats: Stats::default(), + role: MessageRole::User, + }); + } + "assistant" => { + let model_name = session_model + .clone() + .unwrap_or_else(|| "unknown".to_string()); + + // Use token usage if we have it + let stats = if let Some(usage) = ¤t_token_usage { + let total_output_tokens = + usage.output_tokens + usage.reasoning_output_tokens; + + // Subtract cached input tokens from total input tokens + // since Codex input_tokens is a superset that includes cached tokens + let actual_input_tokens = + usage.input_tokens.saturating_sub(usage.cached_input_tokens); + + Stats { + input_tokens: actual_input_tokens, + output_tokens: total_output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: 0, + cached_tokens: usage.cached_input_tokens, + cost: calculate_cost_from_tokens(usage, &model_name), + tool_calls: 0, + ..Default::default() } - _ => {} - } + } else { + Stats::default() + }; + + entries.push(ConversationMessage { + application: Application::CodexCli, + model: Some(model_name), + global_hash: hash_text(&format!( + "{}_{}", + file_path_str, + wrapper.timestamp.to_rfc3339() + )), + local_hash: None, + conversation_hash: hash_text(&file_path_str), + date: wrapper.timestamp, + project_hash: "".to_string(), + stats, + role: MessageRole::Assistant, + }); + + // Clear token usage after using it + current_token_usage = None; } + _ => {} } } } "event_msg" => { let mut payload_bytes = simd_json::to_vec(&wrapper.payload)?; - if let Ok(event) = simd_json::from_slice::(&mut payload_bytes) { - if event.event_type == "token_count" { - if let Some(info) = event.info { - // Use last_token_usage if available, otherwise total_token_usage - current_token_usage = info.last_token_usage.or(info.total_token_usage); - } - } + if let Ok(event) = simd_json::from_slice::(&mut payload_bytes) + && event.event_type == "token_count" + && let Some(info) = event.info + { + // Use last_token_usage if available, otherwise total_token_usage + current_token_usage = info.last_token_usage.or(info.total_token_usage); } } _ => { diff --git a/src/tui.rs b/src/tui.rs index 68abcd1..66459c6 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -896,7 +896,7 @@ fn draw_daily_stats_table( Constraint::Length(6), // Convs Constraint::Length(6), // Tools // Constraint::Length(23), // Lines - Constraint::Min(10), // Models + Constraint::Min(10), // Models ], ) .header(header) From 3db7613eb80ffb08a1bd3232d74067707a7a5735 Mon Sep 17 00:00:00 2001 From: bl-ue <54780737+bl-ue@users.noreply.github.com> Date: Thu, 18 Sep 2025 08:35:29 -0600 Subject: [PATCH 3/3] Lint --- src/analyzers/tests/codex_cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/analyzers/tests/codex_cli.rs b/src/analyzers/tests/codex_cli.rs index 759d66c..36c6273 100644 --- a/src/analyzers/tests/codex_cli.rs +++ b/src/analyzers/tests/codex_cli.rs @@ -76,7 +76,7 @@ fn test_parse_codex_cli_wrapper_format_no_tokens() { let result = parse_codex_cli_jsonl_file(temp_file.path()).unwrap(); // Should have parsed the assistant message - assert!(result.len() >= 1); + assert!(!result.is_empty()); let assistant_msg = result .iter()