From 5f2181640d90b5821de22e3d65106f415a2a5a83 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Wed, 28 Jan 2026 00:06:52 +0800 Subject: [PATCH] feat: add activity tracking and multiline output support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds three major features inspired by claude-hud: ## New Features ### 1. Config Counts Segment - Displays counts of Claude Code configuration items - Shows: CLAUDE.md files, rules, MCPs, and hooks - Format: "2 CLAUDE.md | 3 rules | 5 MCPs | 2 hooks" - 60-second cache with configurable TTL - Integrated into all 9 theme presets ### 2. Tool Activity Line (Line 2) - Shows currently running and recently completed tools - Running tools: "◐ Edit: src/main.rs" - Completed tools with counts: "✓ Read ×3 | ✓ Grep ×2" - Error tools: "✗ Bash ×1" - Configurable limits (default: 2 running, 4 completed) ### 3. Agent Status Line (Line 3) - Shows running and completed subagents - Format: "◐ Explore [haiku]: Finding auth code (2m 15s)" - Completed agents show duration - Smart description truncation (default: 40 chars) ## New CLI Option - `--multiline` / `-m`: Enable multiline output with activity tracking ## Technical Details ### New Files (10) - `src/core/activity/mod.rs` - Module entry point - `src/core/activity/types.rs` - Data structures - `src/core/activity/config_counter.rs` - Config file scanner - `src/core/activity/cache.rs` - TTL-based caching - `src/core/activity/transcript_parser.rs` - JSONL transcript parser - `src/core/activity/tools_line.rs` - Tool activity renderer - `src/core/activity/agents_line.rs` - Agent status renderer - `src/core/multiline.rs` - Multi-line output coordinator - `src/core/segments/config_counts.rs` - Config Counts segment ### Modified Files - All 9 theme presets updated with ConfigCounts segment - UI components updated for ConfigCounts support - CLI updated with --multiline flag ## Usage ```bash # Standard single-line output echo '{"model":...}' | ccline # Multi-line output with activity tracking echo '{"model":...}' | ccline --multiline ``` ## Tests - 130 unit tests added and passing - Comprehensive coverage for all new modules Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 84 ++- Cargo.toml | 3 + src/cli.rs | 4 + src/config/types.rs | 13 +- src/core/activity/agents_line.rs | 651 +++++++++++++++++++ src/core/activity/cache.rs | 437 +++++++++++++ src/core/activity/config_counter.rs | 580 +++++++++++++++++ src/core/activity/mod.rs | 25 + src/core/activity/tools_line.rs | 649 ++++++++++++++++++ src/core/activity/transcript_parser.rs | 622 ++++++++++++++++++ src/core/activity/types.rs | 407 ++++++++++++ src/core/mod.rs | 3 + src/core/multiline.rs | 601 +++++++++++++++++ src/core/segments/config_counts.rs | 275 ++++++++ src/core/segments/mod.rs | 2 + src/core/statusline.rs | 12 + src/main.rs | 27 +- src/ui/app.rs | 2 + src/ui/components/preview.rs | 12 + src/ui/components/segment_list.rs | 1 + src/ui/components/settings.rs | 1 + src/ui/themes/presets.rs | 9 + src/ui/themes/theme_cometix.rs | 18 + src/ui/themes/theme_default.rs | 18 + src/ui/themes/theme_gruvbox.rs | 18 + src/ui/themes/theme_minimal.rs | 18 + src/ui/themes/theme_nord.rs | 30 + src/ui/themes/theme_powerline_dark.rs | 30 + src/ui/themes/theme_powerline_light.rs | 30 + src/ui/themes/theme_powerline_rose_pine.rs | 30 + src/ui/themes/theme_powerline_tokyo_night.rs | 30 + 31 files changed, 4624 insertions(+), 18 deletions(-) create mode 100644 src/core/activity/agents_line.rs create mode 100644 src/core/activity/cache.rs create mode 100644 src/core/activity/config_counter.rs create mode 100644 src/core/activity/mod.rs create mode 100644 src/core/activity/tools_line.rs create mode 100644 src/core/activity/transcript_parser.rs create mode 100644 src/core/activity/types.rs create mode 100644 src/core/multiline.rs create mode 100644 src/core/segments/config_counts.rs diff --git a/Cargo.lock b/Cargo.lock index 6483ac5..efd0a38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,6 +173,7 @@ dependencies = [ "semver", "serde", "serde_json", + "tempfile", "toml", "tree-sitter", "tree-sitter-javascript", @@ -285,7 +286,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix", + "rustix 0.38.44", "signal-hook", "signal-hook-mio", "winapi", @@ -389,6 +390,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "flate2" version = "1.1.2" @@ -431,6 +438,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -647,9 +666,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libredox" @@ -667,6 +686,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.0" @@ -830,6 +855,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "ratatui" version = "0.29.0" @@ -866,7 +897,7 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.16", "libredox", "thiserror", ] @@ -908,7 +939,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom", + "getrandom 0.2.16", "libc", "untrusted", "windows-sys 0.52.0", @@ -923,10 +954,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.60.2", +] + [[package]] name = "rustls" version = "0.23.31" @@ -1149,6 +1193,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.60.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1338,6 +1395,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -1726,6 +1792,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "writeable" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 357f491..fb74f34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,6 @@ tree-sitter-javascript = "0.23" default = ["tui", "self-update", "dirs"] tui = ["ratatui", "crossterm", "ansi_term", "ansi-to-tui", "chrono"] self-update = ["ureq", "semver", "chrono", "dirs"] + +[dev-dependencies] +tempfile = "3.10" diff --git a/src/cli.rs b/src/cli.rs index 1619cc7..ff25e83 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,6 +31,10 @@ pub struct Cli { /// Patch Claude Code cli.js to disable context warnings #[arg(long = "patch")] pub patch: Option, + + /// Enable multiline output with activity tracking (agents/tools lines) + #[arg(short = 'm', long = "multiline")] + pub multiline: bool, } impl Cli { diff --git a/src/config/types.rs b/src/config/types.rs index e5a78dc..22f64ad 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -73,6 +73,7 @@ pub enum SegmentId { Session, OutputStyle, Update, + ConfigCounts, } // Legacy compatibility structure @@ -85,14 +86,17 @@ pub struct SegmentsConfig { } // Data structures compatible with existing main.rs -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct Model { + #[serde(default)] pub id: String, + #[serde(default)] pub display_name: String, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct Workspace { + #[serde(default)] pub current_dir: String, } @@ -110,10 +114,13 @@ pub struct OutputStyle { pub name: String, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct InputData { + #[serde(default)] pub model: Model, + #[serde(default)] pub workspace: Workspace, + #[serde(default)] pub transcript_path: String, pub cost: Option, pub output_style: Option, diff --git a/src/core/activity/agents_line.rs b/src/core/activity/agents_line.rs new file mode 100644 index 0000000..276846a --- /dev/null +++ b/src/core/activity/agents_line.rs @@ -0,0 +1,651 @@ +//! Agent status line rendering +//! +//! This module provides rendering for agent (subagent) status display, +//! showing running and recently completed agents in a formatted line. + +use super::types::{ActivityData, AgentEntry, AgentStatus, format_duration, truncate_string}; +use crate::config::AnsiColor; + +/// Unicode icon for running agent +const RUNNING_ICON: &str = "\u{25D0}"; // Half-filled circle + +/// Unicode icon for completed agent +const COMPLETED_ICON: &str = "\u{2713}"; // Check mark + +/// Configuration for agent status line rendering +#[derive(Debug, Clone)] +pub struct AgentsLineConfig { + /// Maximum number of agents to display (default: 3) + pub max_agents: usize, + /// Maximum length for description text (default: 40) + pub max_description_len: usize, + /// Color for running agent icon + pub running_icon_color: Option, + /// Color for completed agent icon + pub completed_icon_color: Option, + /// Color for agent type text (default: Magenta) + pub agent_type_color: Option, + /// Color for dimmed text (elapsed time, brackets) + pub dim_color: Option, + /// Separator between agents (default: " | ") + pub separator: String, +} + +impl Default for AgentsLineConfig { + fn default() -> Self { + Self { + max_agents: 3, + max_description_len: 40, + running_icon_color: Some(AnsiColor::Color16 { c16: 3 }), // Yellow + completed_icon_color: Some(AnsiColor::Color16 { c16: 2 }), // Green + agent_type_color: Some(AnsiColor::Color16 { c16: 5 }), // Magenta + dim_color: Some(AnsiColor::Color16 { c16: 8 }), // Bright black (gray) + separator: " | ".to_string(), + } + } +} + +/// Render the agents status line +/// +/// # Arguments +/// * `activity` - Activity data containing agent entries +/// * `config` - Configuration for rendering +/// +/// # Returns +/// `Some(String)` with the formatted line, or `None` if no agents to display +/// +/// # Output Format Examples +/// - Running agent: `\u{25D0} Explore [haiku]: Finding auth code (2m 15s)` +/// - Completed agent: `\u{2713} fix: authentication bug (30s)` +/// - Full line: `\u{25D0} Explore [haiku]: Finding auth code (2m 15s) | \u{2713} fix: authentication bug (30s)` +pub fn render_agents_line(activity: &ActivityData, config: &AgentsLineConfig) -> Option { + // 1. Get all running agents + let running_agents: Vec<&AgentEntry> = activity.running_agents(); + + // 2. Get recently completed agents (last 2) + let completed_agents: Vec<&AgentEntry> = activity + .completed_agents() + .into_iter() + .rev() // Most recent first + .take(2) + .collect(); + + // 3. Merge and limit total to max_agents + let mut agents_to_display: Vec<&AgentEntry> = Vec::new(); + + // Add running agents first (they have priority) + for agent in &running_agents { + if agents_to_display.len() >= config.max_agents { + break; + } + agents_to_display.push(agent); + } + + // Add completed agents if there's room + for agent in &completed_agents { + if agents_to_display.len() >= config.max_agents { + break; + } + agents_to_display.push(agent); + } + + // Return None if no agents to display + if agents_to_display.is_empty() { + return None; + } + + // 4. Format each agent + let formatted: Vec = agents_to_display + .iter() + .map(|agent| format_agent(agent, config)) + .collect(); + + Some(formatted.join(&config.separator)) +} + +/// Format a single agent entry +/// +/// Running format: `\u{25D0} {agent_type} [{model}]: {description} ({elapsed})` +/// Completed format: `\u{2713} {agent_type}: {description} ({elapsed})` +fn format_agent(agent: &AgentEntry, config: &AgentsLineConfig) -> String { + let is_running = agent.status == AgentStatus::Running; + + // Icon with color + let icon = if is_running { + apply_color(RUNNING_ICON, config.running_icon_color.as_ref()) + } else { + apply_color(COMPLETED_ICON, config.completed_icon_color.as_ref()) + }; + + // Agent type with color + let agent_type = apply_color(&agent.agent_type, config.agent_type_color.as_ref()); + + // Model part (only for running agents with model) + let model_part = if is_running { + if let Some(model) = &agent.model { + let bracket_open = apply_color("[", config.dim_color.as_ref()); + let bracket_close = apply_color("]", config.dim_color.as_ref()); + format!(" {}{}{}", bracket_open, model, bracket_close) + } else { + String::new() + } + } else { + String::new() + }; + + // Description part + let description_part = if let Some(desc) = &agent.description { + let truncated = truncate_string(desc, config.max_description_len); + format!(": {}", truncated) + } else { + String::new() + }; + + // Elapsed time + let elapsed = format_duration(agent.elapsed()); + let elapsed_formatted = apply_color(&format!("({})", elapsed), config.dim_color.as_ref()); + + format!("{} {}{}{} {}", icon, agent_type, model_part, description_part, elapsed_formatted) +} + +/// Apply ANSI color to text +fn apply_color(text: &str, color: Option<&AnsiColor>) -> String { + match color { + Some(AnsiColor::Color16 { c16 }) => { + let code = if *c16 < 8 { 30 + c16 } else { 90 + (c16 - 8) }; + format!("\x1b[{}m{}\x1b[0m", code, text) + } + Some(AnsiColor::Color256 { c256 }) => { + format!("\x1b[38;5;{}m{}\x1b[0m", c256, text) + } + Some(AnsiColor::Rgb { r, g, b }) => { + format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) + } + None => text.to_string(), + } +} + +/// Strip ANSI escape sequences from text (for testing) +#[cfg(test)] +fn strip_ansi(text: &str) -> String { + let mut result = String::new(); + let mut in_escape = false; + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + in_escape = true; + if chars.peek() == Some(&'[') { + chars.next(); + } + } else if in_escape { + if ch.is_alphabetic() { + in_escape = false; + } + } else { + result.push(ch); + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, SystemTime}; + + fn create_running_agent( + agent_type: &str, + model: Option<&str>, + description: Option<&str>, + ) -> AgentEntry { + AgentEntry { + id: "test_id".to_string(), + agent_type: agent_type.to_string(), + model: model.map(String::from), + description: description.map(String::from), + status: AgentStatus::Running, + start_time: SystemTime::now() - Duration::from_secs(135), // 2m 15s ago + end_time: None, + } + } + + fn create_completed_agent( + agent_type: &str, + description: Option<&str>, + elapsed_secs: u64, + ) -> AgentEntry { + let start = SystemTime::now() - Duration::from_secs(elapsed_secs); + AgentEntry { + id: "test_id".to_string(), + agent_type: agent_type.to_string(), + model: None, + description: description.map(String::from), + status: AgentStatus::Completed, + start_time: start, + end_time: Some(SystemTime::now()), + } + } + + #[test] + fn test_default_config() { + let config = AgentsLineConfig::default(); + assert_eq!(config.max_agents, 3); + assert_eq!(config.max_description_len, 40); + assert_eq!(config.separator, " | "); + assert!(config.running_icon_color.is_some()); + assert!(config.completed_icon_color.is_some()); + assert!(config.agent_type_color.is_some()); + assert!(config.dim_color.is_some()); + } + + #[test] + fn test_render_empty_activity() { + let activity = ActivityData::default(); + let config = AgentsLineConfig::default(); + + let result = render_agents_line(&activity, &config); + assert!(result.is_none()); + } + + #[test] + fn test_render_single_running_agent() { + let mut activity = ActivityData::default(); + activity.agents.push(create_running_agent( + "Explore", + Some("haiku"), + Some("Finding auth code"), + )); + + let config = AgentsLineConfig::default(); + let result = render_agents_line(&activity, &config); + + assert!(result.is_some()); + let line = strip_ansi(&result.unwrap()); + + // Check components are present + assert!(line.contains(RUNNING_ICON)); + assert!(line.contains("Explore")); + assert!(line.contains("[haiku]")); + assert!(line.contains("Finding auth code")); + assert!(line.contains("2m 15s")); + } + + #[test] + fn test_render_running_agent_without_model() { + let mut activity = ActivityData::default(); + activity.agents.push(create_running_agent( + "Explore", + None, + Some("Finding code"), + )); + + let config = AgentsLineConfig::default(); + let result = render_agents_line(&activity, &config); + + assert!(result.is_some()); + let line = strip_ansi(&result.unwrap()); + + // Should not contain brackets for model + assert!(!line.contains("[")); + assert!(!line.contains("]")); + assert!(line.contains("Explore")); + assert!(line.contains("Finding code")); + } + + #[test] + fn test_render_running_agent_without_description() { + let mut activity = ActivityData::default(); + activity.agents.push(create_running_agent( + "Explore", + Some("haiku"), + None, + )); + + let config = AgentsLineConfig::default(); + let result = render_agents_line(&activity, &config); + + assert!(result.is_some()); + let line = strip_ansi(&result.unwrap()); + + // Should not contain colon before description + assert!(line.contains("Explore")); + assert!(line.contains("[haiku]")); + // The format should be: icon Explore [haiku] (elapsed) + // Not: icon Explore [haiku]: (elapsed) + } + + #[test] + fn test_render_completed_agent() { + let mut activity = ActivityData::default(); + activity.agents.push(create_completed_agent( + "fix", + Some("authentication bug"), + 30, + )); + + let config = AgentsLineConfig::default(); + let result = render_agents_line(&activity, &config); + + assert!(result.is_some()); + let line = strip_ansi(&result.unwrap()); + + assert!(line.contains(COMPLETED_ICON)); + assert!(line.contains("fix")); + assert!(line.contains("authentication bug")); + // Completed agents don't show model + assert!(!line.contains("[")); + } + + #[test] + fn test_render_multiple_agents() { + let mut activity = ActivityData::default(); + + // Add running agent + activity.agents.push(create_running_agent( + "Explore", + Some("haiku"), + Some("Finding auth code"), + )); + + // Add completed agent + activity.agents.push(create_completed_agent( + "fix", + Some("authentication bug"), + 30, + )); + + let config = AgentsLineConfig::default(); + let result = render_agents_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Should contain separator + assert!(line.contains(" | ")); + + let stripped = strip_ansi(&line); + assert!(stripped.contains("Explore")); + assert!(stripped.contains("fix")); + } + + #[test] + fn test_max_agents_limit() { + let mut activity = ActivityData::default(); + + // Add 4 running agents + for i in 0..4 { + let mut agent = create_running_agent( + &format!("Agent{}", i), + None, + None, + ); + agent.id = format!("agent_{}", i); + activity.agents.push(agent); + } + + let config = AgentsLineConfig { + max_agents: 2, + ..Default::default() + }; + + let result = render_agents_line(&activity, &config); + assert!(result.is_some()); + + let line = strip_ansi(&result.unwrap()); + + // Should only show 2 agents + assert!(line.contains("Agent0")); + assert!(line.contains("Agent1")); + assert!(!line.contains("Agent2")); + assert!(!line.contains("Agent3")); + } + + #[test] + fn test_running_agents_priority() { + let mut activity = ActivityData::default(); + + // Add 2 completed agents first + for i in 0..2 { + let mut agent = create_completed_agent( + &format!("Completed{}", i), + None, + 30, + ); + agent.id = format!("completed_{}", i); + activity.agents.push(agent); + } + + // Add 2 running agents + for i in 0..2 { + let mut agent = create_running_agent( + &format!("Running{}", i), + None, + None, + ); + agent.id = format!("running_{}", i); + activity.agents.push(agent); + } + + let config = AgentsLineConfig { + max_agents: 2, + ..Default::default() + }; + + let result = render_agents_line(&activity, &config); + assert!(result.is_some()); + + let line = strip_ansi(&result.unwrap()); + + // Running agents should have priority + assert!(line.contains("Running0")); + assert!(line.contains("Running1")); + assert!(!line.contains("Completed")); + } + + #[test] + fn test_description_truncation() { + let mut activity = ActivityData::default(); + activity.agents.push(create_running_agent( + "Explore", + None, + Some("This is a very long description that should be truncated to fit the max length"), + )); + + let config = AgentsLineConfig { + max_description_len: 20, + ..Default::default() + }; + + let result = render_agents_line(&activity, &config); + assert!(result.is_some()); + + let line = strip_ansi(&result.unwrap()); + + // Description should be truncated with ellipsis + assert!(line.contains("This is a very lo...")); + assert!(!line.contains("truncated to fit")); + } + + #[test] + fn test_custom_separator() { + let mut activity = ActivityData::default(); + + activity.agents.push(create_running_agent("Agent1", None, None)); + let mut agent2 = create_running_agent("Agent2", None, None); + agent2.id = "agent_2".to_string(); + activity.agents.push(agent2); + + let config = AgentsLineConfig { + separator: " :: ".to_string(), + ..Default::default() + }; + + let result = render_agents_line(&activity, &config); + assert!(result.is_some()); + + let line = result.unwrap(); + assert!(line.contains(" :: ")); + } + + #[test] + fn test_no_colors_config() { + let mut activity = ActivityData::default(); + activity.agents.push(create_running_agent( + "Explore", + Some("haiku"), + Some("Finding code"), + )); + + let config = AgentsLineConfig { + running_icon_color: None, + completed_icon_color: None, + agent_type_color: None, + dim_color: None, + ..Default::default() + }; + + let result = render_agents_line(&activity, &config); + assert!(result.is_some()); + + let line = result.unwrap(); + + // Should not contain ANSI escape sequences + assert!(!line.contains("\x1b[")); + } + + #[test] + fn test_apply_color_color16() { + let text = "test"; + let color = AnsiColor::Color16 { c16: 2 }; // Green + let result = apply_color(text, Some(&color)); + assert_eq!(result, "\x1b[32mtest\x1b[0m"); + + // Bright color (c16 >= 8) + let bright_color = AnsiColor::Color16 { c16: 10 }; // Bright green + let result = apply_color(text, Some(&bright_color)); + assert_eq!(result, "\x1b[92mtest\x1b[0m"); + } + + #[test] + fn test_apply_color_color256() { + let text = "test"; + let color = AnsiColor::Color256 { c256: 208 }; // Orange + let result = apply_color(text, Some(&color)); + assert_eq!(result, "\x1b[38;5;208mtest\x1b[0m"); + } + + #[test] + fn test_apply_color_rgb() { + let text = "test"; + let color = AnsiColor::Rgb { r: 255, g: 128, b: 0 }; + let result = apply_color(text, Some(&color)); + assert_eq!(result, "\x1b[38;2;255;128;0mtest\x1b[0m"); + } + + #[test] + fn test_apply_color_none() { + let text = "test"; + let result = apply_color(text, None); + assert_eq!(result, "test"); + } + + #[test] + fn test_strip_ansi() { + let text = "\x1b[32mgreen\x1b[0m normal \x1b[38;5;208morange\x1b[0m"; + let result = strip_ansi(text); + assert_eq!(result, "green normal orange"); + } + + #[test] + fn test_completed_agents_most_recent_first() { + let mut activity = ActivityData::default(); + + // Add completed agents with different end times + let mut agent1 = create_completed_agent("OldAgent", None, 100); + agent1.id = "old".to_string(); + agent1.end_time = Some(SystemTime::now() - Duration::from_secs(60)); + + let mut agent2 = create_completed_agent("NewAgent", None, 30); + agent2.id = "new".to_string(); + agent2.end_time = Some(SystemTime::now() - Duration::from_secs(10)); + + activity.agents.push(agent1); + activity.agents.push(agent2); + + let config = AgentsLineConfig { + max_agents: 1, + ..Default::default() + }; + + let result = render_agents_line(&activity, &config); + assert!(result.is_some()); + + let line = strip_ansi(&result.unwrap()); + + // Should show the most recent completed agent (NewAgent) + assert!(line.contains("NewAgent")); + assert!(!line.contains("OldAgent")); + } + + #[test] + fn test_format_agent_running_full() { + let agent = AgentEntry { + id: "test".to_string(), + agent_type: "Explore".to_string(), + model: Some("haiku".to_string()), + description: Some("Finding auth code".to_string()), + status: AgentStatus::Running, + start_time: SystemTime::now() - Duration::from_secs(135), + end_time: None, + }; + + let config = AgentsLineConfig { + running_icon_color: None, + completed_icon_color: None, + agent_type_color: None, + dim_color: None, + ..Default::default() + }; + + let result = format_agent(&agent, &config); + + // Format: icon agent_type [model]: description (elapsed) + assert!(result.starts_with(RUNNING_ICON)); + assert!(result.contains("Explore")); + assert!(result.contains("[haiku]")); + assert!(result.contains(": Finding auth code")); + assert!(result.contains("(2m 15s)")); + } + + #[test] + fn test_format_agent_completed() { + let agent = AgentEntry { + id: "test".to_string(), + agent_type: "fix".to_string(), + model: Some("sonnet".to_string()), // Model should be ignored for completed + description: Some("authentication bug".to_string()), + status: AgentStatus::Completed, + start_time: SystemTime::now() - Duration::from_secs(30), + end_time: Some(SystemTime::now()), + }; + + let config = AgentsLineConfig { + running_icon_color: None, + completed_icon_color: None, + agent_type_color: None, + dim_color: None, + ..Default::default() + }; + + let result = format_agent(&agent, &config); + + // Format: icon agent_type: description (elapsed) + // Note: completed agents don't show model + assert!(result.starts_with(COMPLETED_ICON)); + assert!(result.contains("fix")); + assert!(!result.contains("[sonnet]")); // Model not shown for completed + assert!(result.contains(": authentication bug")); + } +} diff --git a/src/core/activity/cache.rs b/src/core/activity/cache.rs new file mode 100644 index 0000000..4bc03a6 --- /dev/null +++ b/src/core/activity/cache.rs @@ -0,0 +1,437 @@ +//! Configuration counts caching with TTL support +//! +//! This module provides caching functionality for config counts to avoid +//! repeatedly scanning the filesystem. The cache is stored in a JSON file +//! at `~/.claude/ccline/.config-cache.json` with a configurable TTL. + +use super::config_counter::count_configs; +use super::types::ConfigCounts; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Default cache TTL in seconds +pub const DEFAULT_TTL_SECS: u64 = 60; + +/// Cache file name +const CACHE_FILE_NAME: &str = ".config-cache.json"; + +/// Cache entry structure stored in the cache file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheEntry { + /// Cached configuration counts + pub counts: ConfigCounts, + /// Unix timestamp when the cache was created (seconds since epoch) + pub timestamp_secs: u64, + /// Current working directory used when counting (for cache invalidation) + pub cwd: Option, +} + +impl CacheEntry { + /// Create a new cache entry with the current timestamp + pub fn new(counts: ConfigCounts, cwd: Option) -> Self { + let timestamp_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + counts, + timestamp_secs, + cwd, + } + } + + /// Check if the cache entry is still valid + /// + /// A cache entry is valid if: + /// 1. It hasn't expired (current time - timestamp < ttl) + /// 2. The cwd matches the requested cwd + pub fn is_valid(&self, cwd: Option<&str>, ttl_secs: u64) -> bool { + // Check cwd match + let cwd_matches = match (&self.cwd, cwd) { + (Some(cached_cwd), Some(requested_cwd)) => cached_cwd == requested_cwd, + (None, None) => true, + _ => false, + }; + + if !cwd_matches { + return false; + } + + // Check TTL + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let age = current_time.saturating_sub(self.timestamp_secs); + age < ttl_secs + } +} + +/// Get the cache file path +/// +/// Returns `~/.claude/ccline/.config-cache.json` +fn get_cache_path() -> Option { + let home = dirs::home_dir()?; + Some(home.join(".claude").join("ccline").join(CACHE_FILE_NAME)) +} + +/// Read the cache entry from the cache file +fn read_cache() -> Option { + let cache_path = get_cache_path()?; + + if !cache_path.exists() { + return None; + } + + let content = fs::read_to_string(&cache_path).ok()?; + serde_json::from_str(&content).ok() +} + +/// Write a cache entry to the cache file +fn write_cache(entry: &CacheEntry) -> bool { + let cache_path = match get_cache_path() { + Some(p) => p, + None => return false, + }; + + // Ensure parent directory exists + if let Some(parent) = cache_path.parent() { + if let Err(_) = fs::create_dir_all(parent) { + return false; + } + } + + // Serialize and write + let content = match serde_json::to_string_pretty(entry) { + Ok(c) => c, + Err(_) => return false, + }; + + fs::write(&cache_path, content).is_ok() +} + +/// Get configuration counts with caching support +/// +/// This function checks the cache first and returns cached counts if valid. +/// If the cache is invalid or missing, it calls `count_configs()` to get +/// fresh counts, saves them to the cache, and returns them. +/// +/// # Arguments +/// +/// * `cwd` - Optional current working directory for project scope scanning +/// * `ttl_secs` - Optional TTL in seconds (defaults to 60 seconds) +/// +/// # Returns +/// +/// A `ConfigCounts` struct with the configuration counts +/// +/// # Cache Behavior +/// +/// - Cache file location: `~/.claude/ccline/.config-cache.json` +/// - Cache is invalidated if: +/// - TTL has expired +/// - The cwd doesn't match the cached cwd +/// - The cache file is missing or corrupted +/// - On any I/O error, fresh counts are returned (fail-safe) +/// +/// # Example +/// +/// ```ignore +/// use ccometixline::core::activity::cache::get_config_counts_cached; +/// +/// // Get counts with default TTL (60 seconds) +/// let counts = get_config_counts_cached(Some("/path/to/project"), None); +/// +/// // Get counts with custom TTL (30 seconds) +/// let counts = get_config_counts_cached(Some("/path/to/project"), Some(30)); +/// ``` +pub fn get_config_counts_cached(cwd: Option<&str>, ttl_secs: Option) -> ConfigCounts { + let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); + + // Try to read from cache + if let Some(entry) = read_cache() { + if entry.is_valid(cwd, ttl) { + return entry.counts; + } + } + + // Cache miss or invalid - get fresh counts + let counts = count_configs(cwd); + + // Save to cache (ignore errors - caching is best-effort) + let entry = CacheEntry::new(counts.clone(), cwd.map(String::from)); + let _ = write_cache(&entry); + + counts +} + +/// Invalidate the cache by deleting the cache file +/// +/// This function removes the cache file, forcing the next call to +/// `get_config_counts_cached()` to fetch fresh counts. +/// +/// # Returns +/// +/// `true` if the cache was successfully invalidated (or didn't exist), +/// `false` if there was an error deleting the file. +/// +/// # Example +/// +/// ```ignore +/// use ccometixline::core::activity::cache::invalidate_cache; +/// +/// // Force refresh on next call +/// invalidate_cache(); +/// ``` +pub fn invalidate_cache() -> bool { + let cache_path = match get_cache_path() { + Some(p) => p, + None => return true, // No cache path means nothing to invalidate + }; + + if !cache_path.exists() { + return true; // Nothing to delete + } + + fs::remove_file(&cache_path).is_ok() +} + +/// Get the cache file path (for testing/debugging) +/// +/// Returns the full path to the cache file, or None if the home directory +/// cannot be determined. +pub fn cache_file_path() -> Option { + get_cache_path() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::TempDir; + + /// Helper to create a temporary cache directory structure + fn setup_temp_cache_dir() -> TempDir { + tempfile::tempdir().expect("Failed to create temp dir") + } + + /// Helper to create a file with content + fn create_file(path: &PathBuf, content: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent dirs"); + } + let mut file = File::create(path).expect("Failed to create file"); + file.write_all(content.as_bytes()) + .expect("Failed to write file"); + } + + #[test] + fn test_cache_entry_new() { + let counts = ConfigCounts { + claude_md_count: 1, + rules_count: 2, + mcp_count: 3, + hooks_count: 4, + }; + + let entry = CacheEntry::new(counts.clone(), Some("/test/path".to_string())); + + assert_eq!(entry.counts.claude_md_count, 1); + assert_eq!(entry.counts.rules_count, 2); + assert_eq!(entry.counts.mcp_count, 3); + assert_eq!(entry.counts.hooks_count, 4); + assert_eq!(entry.cwd, Some("/test/path".to_string())); + assert!(entry.timestamp_secs > 0); + } + + #[test] + fn test_cache_entry_is_valid_matching_cwd() { + let counts = ConfigCounts::default(); + let entry = CacheEntry::new(counts, Some("/test/path".to_string())); + + // Same cwd should be valid + assert!(entry.is_valid(Some("/test/path"), DEFAULT_TTL_SECS)); + + // Different cwd should be invalid + assert!(!entry.is_valid(Some("/other/path"), DEFAULT_TTL_SECS)); + + // None vs Some should be invalid + assert!(!entry.is_valid(None, DEFAULT_TTL_SECS)); + } + + #[test] + fn test_cache_entry_is_valid_none_cwd() { + let counts = ConfigCounts::default(); + let entry = CacheEntry::new(counts, None); + + // None cwd should match None + assert!(entry.is_valid(None, DEFAULT_TTL_SECS)); + + // None should not match Some + assert!(!entry.is_valid(Some("/test/path"), DEFAULT_TTL_SECS)); + } + + #[test] + fn test_cache_entry_is_valid_expired() { + let counts = ConfigCounts::default(); + let mut entry = CacheEntry::new(counts, None); + + // Set timestamp to 100 seconds ago + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + entry.timestamp_secs = current_time.saturating_sub(100); + + // Should be invalid with 60 second TTL + assert!(!entry.is_valid(None, 60)); + + // Should be valid with 200 second TTL + assert!(entry.is_valid(None, 200)); + } + + #[test] + fn test_cache_entry_serialization() { + let counts = ConfigCounts { + claude_md_count: 1, + rules_count: 2, + mcp_count: 3, + hooks_count: 4, + }; + let entry = CacheEntry::new(counts, Some("/test".to_string())); + + // Serialize + let json = serde_json::to_string(&entry).expect("Failed to serialize"); + + // Deserialize + let deserialized: CacheEntry = + serde_json::from_str(&json).expect("Failed to deserialize"); + + assert_eq!(deserialized.counts.claude_md_count, 1); + assert_eq!(deserialized.counts.rules_count, 2); + assert_eq!(deserialized.counts.mcp_count, 3); + assert_eq!(deserialized.counts.hooks_count, 4); + assert_eq!(deserialized.cwd, Some("/test".to_string())); + assert_eq!(deserialized.timestamp_secs, entry.timestamp_secs); + } + + #[test] + fn test_cache_entry_json_format() { + let counts = ConfigCounts { + claude_md_count: 2, + rules_count: 5, + mcp_count: 3, + hooks_count: 1, + }; + let mut entry = CacheEntry::new(counts, Some("/project".to_string())); + entry.timestamp_secs = 1700000000; // Fixed timestamp for testing + + let json = serde_json::to_string_pretty(&entry).expect("Failed to serialize"); + + // Verify JSON structure + assert!(json.contains("\"claude_md_count\": 2")); + assert!(json.contains("\"rules_count\": 5")); + assert!(json.contains("\"mcp_count\": 3")); + assert!(json.contains("\"hooks_count\": 1")); + assert!(json.contains("\"timestamp_secs\": 1700000000")); + assert!(json.contains("\"cwd\": \"/project\"")); + } + + #[test] + fn test_get_cache_path() { + let path = get_cache_path(); + + // Should return Some on most systems + if let Some(p) = path { + assert!(p.ends_with(".config-cache.json")); + assert!(p.to_string_lossy().contains(".claude")); + assert!(p.to_string_lossy().contains("ccline")); + } + } + + #[test] + fn test_invalidate_cache_nonexistent() { + // Invalidating a non-existent cache should succeed + // (This test may affect the actual cache file, so we just verify it doesn't panic) + let result = invalidate_cache(); + // Result should be true (either deleted or didn't exist) + assert!(result); + } + + #[test] + fn test_get_config_counts_cached_returns_valid_counts() { + // This test verifies the function returns valid counts + // Note: This may use the actual cache or create a new one + let counts = get_config_counts_cached(None, Some(1)); // 1 second TTL + + // Verify we get a valid ConfigCounts struct + // Values depend on actual system configuration + assert!(counts.claude_md_count <= 100); // Sanity check + assert!(counts.rules_count <= 1000); + assert!(counts.mcp_count <= 100); + assert!(counts.hooks_count <= 100); + } + + #[test] + fn test_default_ttl_value() { + assert_eq!(DEFAULT_TTL_SECS, 60); + } + + #[test] + fn test_cache_file_path_function() { + let path = cache_file_path(); + + // Should match get_cache_path + assert_eq!(path, get_cache_path()); + } + + // Integration test for cache read/write cycle + // Note: This test uses the actual cache location + #[test] + fn test_cache_integration() { + // First, invalidate any existing cache + invalidate_cache(); + + // Get counts (should create cache) + let counts1 = get_config_counts_cached(None, Some(60)); + + // Get counts again (should use cache) + let counts2 = get_config_counts_cached(None, Some(60)); + + // Both should return the same values + assert_eq!(counts1.claude_md_count, counts2.claude_md_count); + assert_eq!(counts1.rules_count, counts2.rules_count); + assert_eq!(counts1.mcp_count, counts2.mcp_count); + assert_eq!(counts1.hooks_count, counts2.hooks_count); + + // Clean up + invalidate_cache(); + } + + #[test] + fn test_cache_cwd_change_invalidates() { + // Invalidate any existing cache + invalidate_cache(); + + // Get counts with cwd1 + let _counts1 = get_config_counts_cached(Some("/path1"), Some(60)); + + // Read the cache directly + let cached = read_cache(); + assert!(cached.is_some()); + + let entry = cached.unwrap(); + assert_eq!(entry.cwd, Some("/path1".to_string())); + + // The cache should be invalid for a different cwd + assert!(!entry.is_valid(Some("/path2"), 60)); + + // Clean up + invalidate_cache(); + } +} diff --git a/src/core/activity/config_counter.rs b/src/core/activity/config_counter.rs new file mode 100644 index 0000000..a1af867 --- /dev/null +++ b/src/core/activity/config_counter.rs @@ -0,0 +1,580 @@ +//! Configuration counting logic for Claude Code environment +//! +//! This module scans user and project scope configuration files to count: +//! - CLAUDE.md files +//! - Rule files (.md in rules directories) +//! - MCP servers (minus disabled ones) +//! - Hooks +//! +//! Based on claude-hud's config-reader.ts implementation. + +use super::types::ConfigCounts; +use serde_json::Value; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Get the user's home directory +fn get_home_dir() -> Option { + dirs::home_dir() +} + +/// Read MCP server names from a JSON config file +/// +/// Looks for `mcpServers` object and returns the set of server names (keys). +fn get_mcp_server_names(file_path: &Path) -> HashSet { + let mut servers = HashSet::new(); + + if !file_path.exists() { + return servers; + } + + let content = match fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => return servers, + }; + + let config: Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return servers, + }; + + if let Some(mcp_servers) = config.get("mcpServers") { + if let Some(obj) = mcp_servers.as_object() { + for key in obj.keys() { + servers.insert(key.clone()); + } + } + } + + servers +} + +/// Read disabled MCP server names from a JSON config file +/// +/// Looks for the specified key (e.g., `disabledMcpServers` or `disabledMcpjsonServers`) +/// and returns the set of disabled server names. +fn get_disabled_mcp_servers(file_path: &Path, key: &str) -> HashSet { + let mut disabled = HashSet::new(); + + if !file_path.exists() { + return disabled; + } + + let content = match fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => return disabled, + }; + + let config: Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return disabled, + }; + + if let Some(disabled_array) = config.get(key) { + if let Some(arr) = disabled_array.as_array() { + for item in arr { + if let Some(name) = item.as_str() { + disabled.insert(name.to_string()); + } + } + } + } + + disabled +} + +/// Count hooks in a JSON config file +/// +/// Looks for `hooks` object and returns the number of hook keys. +fn count_hooks_in_file(file_path: &Path) -> u32 { + if !file_path.exists() { + return 0; + } + + let content = match fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => return 0, + }; + + let config: Value = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return 0, + }; + + if let Some(hooks) = config.get("hooks") { + if let Some(obj) = hooks.as_object() { + return obj.len() as u32; + } + } + + 0 +} + +/// Recursively count .md files in a rules directory +fn count_rules_in_dir(rules_dir: &Path) -> u32 { + if !rules_dir.exists() || !rules_dir.is_dir() { + return 0; + } + + let mut count = 0; + + let entries = match fs::read_dir(rules_dir) { + Ok(e) => e, + Err(_) => return 0, + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_dir() { + // Recursively count in subdirectories + count += count_rules_in_dir(&path); + } else if path.is_file() { + // Count .md files + if let Some(ext) = path.extension() { + if ext.eq_ignore_ascii_case("md") { + count += 1; + } + } + } + } + + count +} + +/// Check if a file exists +fn file_exists(path: &Path) -> bool { + path.exists() && path.is_file() +} + +/// Count all configurations from user and project scopes +/// +/// # Arguments +/// +/// * `cwd` - Optional current working directory for project scope scanning. +/// If None, only user scope is scanned. +/// +/// # Returns +/// +/// A `ConfigCounts` struct containing counts for: +/// - `claude_md_count`: Number of CLAUDE.md files found +/// - `rules_count`: Number of rule files (.md in rules directories) +/// - `mcp_count`: Number of MCP servers (enabled - disabled) +/// - `hooks_count`: Number of hooks configured +/// +/// # Scope Details +/// +/// ## User Scope +/// - `~/.claude/CLAUDE.md` +/// - `~/.claude/rules/*.md` (recursive) +/// - `~/.claude/settings.json` (mcpServers keys, hooks keys) +/// - `~/.claude.json` (mcpServers keys, disabledMcpServers array) +/// +/// ## Project Scope (if cwd provided) +/// - `{cwd}/CLAUDE.md` +/// - `{cwd}/CLAUDE.local.md` +/// - `{cwd}/.claude/CLAUDE.md` +/// - `{cwd}/.claude/CLAUDE.local.md` +/// - `{cwd}/.claude/rules/*.md` (recursive) +/// - `{cwd}/.mcp.json` (mcpServers keys) +/// - `{cwd}/.claude/settings.json` (mcpServers keys, hooks keys) +/// - `{cwd}/.claude/settings.local.json` (mcpServers keys, hooks keys, disabledMcpjsonServers array) +/// +/// # MCP Counting +/// +/// MCP servers are counted per scope with disabled servers subtracted: +/// - User scope: servers from `~/.claude/settings.json` and `~/.claude.json`, +/// minus `disabledMcpServers` from `~/.claude.json` +/// - Project scope: servers from `.mcp.json`, `.claude/settings.json`, and +/// `.claude/settings.local.json`, minus `disabledMcpjsonServers` from +/// `.claude/settings.local.json` +/// +/// Same-name MCPs in user and project scope count separately (no cross-scope deduplication). +pub fn count_configs(cwd: Option<&str>) -> ConfigCounts { + let mut claude_md_count: u32 = 0; + let mut rules_count: u32 = 0; + let mut hooks_count: u32 = 0; + + // Collect MCP servers per scope for proper disabled filtering + let mut user_mcp_servers: HashSet = HashSet::new(); + let mut project_mcp_servers: HashSet = HashSet::new(); + + // Get home directory + let home_dir = match get_home_dir() { + Some(h) => h, + None => { + // If we can't get home dir, return empty counts + return ConfigCounts::default(); + } + }; + + let claude_dir = home_dir.join(".claude"); + + // === USER SCOPE === + + // ~/.claude/CLAUDE.md + if file_exists(&claude_dir.join("CLAUDE.md")) { + claude_md_count += 1; + } + + // ~/.claude/rules/*.md (recursive) + rules_count += count_rules_in_dir(&claude_dir.join("rules")); + + // ~/.claude/settings.json (MCPs and hooks) + let user_settings = claude_dir.join("settings.json"); + for name in get_mcp_server_names(&user_settings) { + user_mcp_servers.insert(name); + } + hooks_count += count_hooks_in_file(&user_settings); + + // ~/.claude.json (additional user-scope MCPs) + let user_claude_json = home_dir.join(".claude.json"); + for name in get_mcp_server_names(&user_claude_json) { + user_mcp_servers.insert(name); + } + + // Get disabled user-scope MCPs from ~/.claude.json + let disabled_user_mcps = get_disabled_mcp_servers(&user_claude_json, "disabledMcpServers"); + for name in &disabled_user_mcps { + user_mcp_servers.remove(name); + } + + // === PROJECT SCOPE === + + if let Some(cwd_str) = cwd { + let cwd_path = Path::new(cwd_str); + + // {cwd}/CLAUDE.md + if file_exists(&cwd_path.join("CLAUDE.md")) { + claude_md_count += 1; + } + + // {cwd}/CLAUDE.local.md + if file_exists(&cwd_path.join("CLAUDE.local.md")) { + claude_md_count += 1; + } + + // {cwd}/.claude/CLAUDE.md + if file_exists(&cwd_path.join(".claude").join("CLAUDE.md")) { + claude_md_count += 1; + } + + // {cwd}/.claude/CLAUDE.local.md + if file_exists(&cwd_path.join(".claude").join("CLAUDE.local.md")) { + claude_md_count += 1; + } + + // {cwd}/.claude/rules/*.md (recursive) + rules_count += count_rules_in_dir(&cwd_path.join(".claude").join("rules")); + + // {cwd}/.mcp.json (project MCP config) - tracked separately for disabled filtering + let mcp_json_path = cwd_path.join(".mcp.json"); + let mut mcp_json_servers = get_mcp_server_names(&mcp_json_path); + + // {cwd}/.claude/settings.json (project settings) + let project_settings = cwd_path.join(".claude").join("settings.json"); + for name in get_mcp_server_names(&project_settings) { + project_mcp_servers.insert(name); + } + hooks_count += count_hooks_in_file(&project_settings); + + // {cwd}/.claude/settings.local.json (local project settings) + let local_settings = cwd_path.join(".claude").join("settings.local.json"); + for name in get_mcp_server_names(&local_settings) { + project_mcp_servers.insert(name); + } + hooks_count += count_hooks_in_file(&local_settings); + + // Get disabled .mcp.json servers from settings.local.json + let disabled_mcp_json_servers = + get_disabled_mcp_servers(&local_settings, "disabledMcpjsonServers"); + for name in &disabled_mcp_json_servers { + mcp_json_servers.remove(name); + } + + // Add remaining .mcp.json servers to project set + for name in mcp_json_servers { + project_mcp_servers.insert(name); + } + } + + // Total MCP count = user servers + project servers + // Note: Deduplication only occurs within each scope, not across scopes. + // A server with the same name in both user and project scope counts as 2 (separate configs). + let mcp_count = (user_mcp_servers.len() + project_mcp_servers.len()) as u32; + + ConfigCounts { + claude_md_count, + rules_count, + mcp_count, + hooks_count, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::{self, File}; + use std::io::Write; + use tempfile::TempDir; + + /// Helper to create a test directory structure + fn setup_test_dir() -> TempDir { + tempfile::tempdir().expect("Failed to create temp dir") + } + + /// Helper to create a file with content + fn create_file(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("Failed to create parent dirs"); + } + let mut file = File::create(path).expect("Failed to create file"); + file.write_all(content.as_bytes()) + .expect("Failed to write file"); + } + + #[test] + fn test_get_mcp_server_names_empty() { + let temp = setup_test_dir(); + let path = temp.path().join("nonexistent.json"); + let servers = get_mcp_server_names(&path); + assert!(servers.is_empty()); + } + + #[test] + fn test_get_mcp_server_names_valid() { + let temp = setup_test_dir(); + let path = temp.path().join("settings.json"); + create_file( + &path, + r#"{ + "mcpServers": { + "server1": {"command": "test"}, + "server2": {"command": "test2"} + } + }"#, + ); + + let servers = get_mcp_server_names(&path); + assert_eq!(servers.len(), 2); + assert!(servers.contains("server1")); + assert!(servers.contains("server2")); + } + + #[test] + fn test_get_mcp_server_names_invalid_json() { + let temp = setup_test_dir(); + let path = temp.path().join("invalid.json"); + create_file(&path, "not valid json"); + + let servers = get_mcp_server_names(&path); + assert!(servers.is_empty()); + } + + #[test] + fn test_get_mcp_server_names_no_mcp_servers_key() { + let temp = setup_test_dir(); + let path = temp.path().join("settings.json"); + create_file(&path, r#"{"hooks": {}}"#); + + let servers = get_mcp_server_names(&path); + assert!(servers.is_empty()); + } + + #[test] + fn test_get_disabled_mcp_servers() { + let temp = setup_test_dir(); + let path = temp.path().join("claude.json"); + create_file( + &path, + r#"{ + "disabledMcpServers": ["server1", "server2"], + "disabledMcpjsonServers": ["server3"] + }"#, + ); + + let disabled = get_disabled_mcp_servers(&path, "disabledMcpServers"); + assert_eq!(disabled.len(), 2); + assert!(disabled.contains("server1")); + assert!(disabled.contains("server2")); + + let disabled_json = get_disabled_mcp_servers(&path, "disabledMcpjsonServers"); + assert_eq!(disabled_json.len(), 1); + assert!(disabled_json.contains("server3")); + } + + #[test] + fn test_get_disabled_mcp_servers_with_non_strings() { + let temp = setup_test_dir(); + let path = temp.path().join("claude.json"); + create_file( + &path, + r#"{ + "disabledMcpServers": ["valid", 123, null, "also_valid"] + }"#, + ); + + let disabled = get_disabled_mcp_servers(&path, "disabledMcpServers"); + assert_eq!(disabled.len(), 2); + assert!(disabled.contains("valid")); + assert!(disabled.contains("also_valid")); + } + + #[test] + fn test_count_hooks_in_file() { + let temp = setup_test_dir(); + let path = temp.path().join("settings.json"); + create_file( + &path, + r#"{ + "hooks": { + "PreToolUse": [{"command": "test"}], + "PostToolUse": [{"command": "test2"}], + "Notification": [{"command": "test3"}] + } + }"#, + ); + + let count = count_hooks_in_file(&path); + assert_eq!(count, 3); + } + + #[test] + fn test_count_hooks_in_file_no_hooks() { + let temp = setup_test_dir(); + let path = temp.path().join("settings.json"); + create_file(&path, r#"{"mcpServers": {}}"#); + + let count = count_hooks_in_file(&path); + assert_eq!(count, 0); + } + + #[test] + fn test_count_rules_in_dir() { + let temp = setup_test_dir(); + let rules_dir = temp.path().join("rules"); + + // Create some rule files + create_file(&rules_dir.join("rule1.md"), "# Rule 1"); + create_file(&rules_dir.join("rule2.md"), "# Rule 2"); + create_file(&rules_dir.join("subdir").join("rule3.md"), "# Rule 3"); + create_file(&rules_dir.join("not_a_rule.txt"), "Not a rule"); + + let count = count_rules_in_dir(&rules_dir); + assert_eq!(count, 3); + } + + #[test] + fn test_count_rules_in_dir_empty() { + let temp = setup_test_dir(); + let rules_dir = temp.path().join("rules"); + fs::create_dir_all(&rules_dir).expect("Failed to create rules dir"); + + let count = count_rules_in_dir(&rules_dir); + assert_eq!(count, 0); + } + + #[test] + fn test_count_rules_in_dir_nonexistent() { + let temp = setup_test_dir(); + let rules_dir = temp.path().join("nonexistent"); + + let count = count_rules_in_dir(&rules_dir); + assert_eq!(count, 0); + } + + #[test] + fn test_count_configs_project_scope() { + let temp = setup_test_dir(); + let cwd = temp.path(); + + // Create project CLAUDE.md files + create_file(&cwd.join("CLAUDE.md"), "# Project CLAUDE"); + create_file(&cwd.join("CLAUDE.local.md"), "# Local CLAUDE"); + create_file(&cwd.join(".claude").join("CLAUDE.md"), "# .claude CLAUDE"); + + // Create project rules + create_file( + &cwd.join(".claude").join("rules").join("rule1.md"), + "# Rule", + ); + + // Create project MCP config + create_file( + &cwd.join(".mcp.json"), + r#"{"mcpServers": {"project_mcp": {}}}"#, + ); + + // Create project settings with hooks + create_file( + &cwd.join(".claude").join("settings.json"), + r#"{ + "mcpServers": {"settings_mcp": {}}, + "hooks": {"PreToolUse": []} + }"#, + ); + + let counts = count_configs(Some(cwd.to_str().unwrap())); + + // Note: User scope counts depend on actual home directory state + // We can only verify project scope additions + assert!(counts.claude_md_count >= 3); // At least 3 from project + assert!(counts.rules_count >= 1); // At least 1 from project + assert!(counts.mcp_count >= 2); // At least 2 from project (project_mcp + settings_mcp) + assert!(counts.hooks_count >= 1); // At least 1 from project + } + + #[test] + fn test_count_configs_disabled_mcp_servers() { + let temp = setup_test_dir(); + let cwd = temp.path(); + + // Create .mcp.json with servers + create_file( + &cwd.join(".mcp.json"), + r#"{"mcpServers": {"enabled_mcp": {}, "disabled_mcp": {}}}"#, + ); + + // Create settings.local.json that disables one server + create_file( + &cwd.join(".claude").join("settings.local.json"), + r#"{"disabledMcpjsonServers": ["disabled_mcp"]}"#, + ); + + let counts = count_configs(Some(cwd.to_str().unwrap())); + + // Only enabled_mcp should be counted from project scope + // (plus any from user scope) + assert!(counts.mcp_count >= 1); + } + + #[test] + fn test_count_configs_no_cwd() { + // Test with no cwd - only user scope + let counts = count_configs(None); + + // Should return valid counts (may be 0 or more depending on user's actual config) + // Just verify it doesn't panic and returns a valid struct + assert!(counts.claude_md_count <= 100); // Sanity check + assert!(counts.rules_count <= 1000); + assert!(counts.mcp_count <= 100); + assert!(counts.hooks_count <= 100); + } + + #[test] + fn test_file_exists() { + let temp = setup_test_dir(); + let file_path = temp.path().join("test.txt"); + + assert!(!file_exists(&file_path)); + + create_file(&file_path, "test"); + assert!(file_exists(&file_path)); + + // Directory should return false + let dir_path = temp.path().join("testdir"); + fs::create_dir_all(&dir_path).expect("Failed to create dir"); + assert!(!file_exists(&dir_path)); + } +} diff --git a/src/core/activity/mod.rs b/src/core/activity/mod.rs new file mode 100644 index 0000000..a041de9 --- /dev/null +++ b/src/core/activity/mod.rs @@ -0,0 +1,25 @@ +//! Activity tracking and configuration counting module +//! +//! This module provides functionality for: +//! - Tracking tool and agent activity from transcripts +//! - Counting Claude Code configuration items (CLAUDE.md, rules, MCPs, hooks) +//! - Caching configuration counts with TTL support +//! - Parsing transcript files for activity data + +pub mod agents_line; +pub mod cache; +pub mod config_counter; +pub mod tools_line; +pub mod transcript_parser; +pub mod types; + +// Re-export commonly used items +pub use agents_line::{render_agents_line, AgentsLineConfig}; +pub use cache::{get_config_counts_cached, invalidate_cache, DEFAULT_TTL_SECS}; +pub use config_counter::count_configs; +pub use tools_line::{render_tools_line, ToolsLineConfig}; +pub use transcript_parser::parse_transcript_activity; +pub use types::{ + format_duration, truncate_path, truncate_string, ActivityData, AgentEntry, AgentStatus, + ConfigCounts, ToolEntry, ToolStatus, +}; diff --git a/src/core/activity/tools_line.rs b/src/core/activity/tools_line.rs new file mode 100644 index 0000000..3f6b5a1 --- /dev/null +++ b/src/core/activity/tools_line.rs @@ -0,0 +1,649 @@ +//! Tool Activity line rendering +//! +//! This module provides rendering logic for displaying tool call activity +//! in the statusline. It shows running tools with their targets and +//! completed tools with call counts. +//! +//! Output format examples: +//! - Running tool: `[running_icon] Edit: src/main.rs` +//! - Completed tool: `[completed_icon] Read x3` +//! - Error tool: `[error_icon] Bash x1` +//! - Full line: `[running_icon] Edit: src/main.rs | [completed_icon] Read x3 | [completed_icon] Grep x2` + +use super::types::{truncate_path, ActivityData, ToolEntry, ToolStatus}; +use crate::config::AnsiColor; +use std::collections::HashMap; + +/// Unicode icons for tool status +pub mod icons { + /// Running tool icon (half circle) + pub const RUNNING: &str = "\u{25D0}"; + /// Completed tool icon (check mark) + pub const COMPLETED: &str = "\u{2713}"; + /// Error tool icon (cross mark) + pub const ERROR: &str = "\u{2717}"; +} + +/// Configuration for tools line rendering +#[derive(Debug, Clone)] +pub struct ToolsLineConfig { + /// Maximum number of running tools to display (default: 2) + pub max_running: usize, + /// Maximum number of completed tool types to display (default: 4) + pub max_completed: usize, + /// Maximum length for target paths/strings (default: 20) + pub max_target_len: usize, + /// Color for running tool icon (default: Yellow) + pub running_icon_color: Option, + /// Color for completed tool icon (default: Green) + pub completed_icon_color: Option, + /// Color for error tool icon (default: Red) + pub error_icon_color: Option, + /// Color for tool names (default: Cyan) + pub tool_name_color: Option, + /// Color for dimmed text like counts and targets (default: Gray) + pub dim_color: Option, + /// Separator between tool entries (default: " | ") + pub separator: String, +} + +impl Default for ToolsLineConfig { + fn default() -> Self { + Self { + max_running: 2, + max_completed: 4, + max_target_len: 20, + running_icon_color: Some(AnsiColor::Color16 { c16: 3 }), // Yellow + completed_icon_color: Some(AnsiColor::Color16 { c16: 2 }), // Green + error_icon_color: Some(AnsiColor::Color16 { c16: 1 }), // Red + tool_name_color: Some(AnsiColor::Color16 { c16: 6 }), // Cyan + dim_color: Some(AnsiColor::Color16 { c16: 8 }), // Bright Black (Gray) + separator: " | ".to_string(), + } + } +} + +/// Statistics for a completed tool type +#[derive(Debug, Clone)] +struct ToolStats { + /// Tool name + name: String, + /// Number of successful completions + completed_count: usize, + /// Number of errors + error_count: usize, +} + +impl ToolStats { + fn new(name: String) -> Self { + Self { + name, + completed_count: 0, + error_count: 0, + } + } + + /// Total invocations (completed + errors) + fn total(&self) -> usize { + self.completed_count + self.error_count + } + + /// Whether this tool has any errors + fn has_errors(&self) -> bool { + self.error_count > 0 + } +} + +/// Apply ANSI color to text +fn apply_color(text: &str, color: Option<&AnsiColor>) -> String { + match color { + Some(AnsiColor::Color16 { c16 }) => { + let code = if *c16 < 8 { 30 + c16 } else { 90 + (c16 - 8) }; + format!("\x1b[{}m{}\x1b[0m", code, text) + } + Some(AnsiColor::Color256 { c256 }) => { + format!("\x1b[38;5;{}m{}\x1b[0m", c256, text) + } + Some(AnsiColor::Rgb { r, g, b }) => { + format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) + } + None => text.to_string(), + } +} + +/// Render a single running tool entry +fn render_running_tool(tool: &ToolEntry, config: &ToolsLineConfig) -> String { + let icon = apply_color(icons::RUNNING, config.running_icon_color.as_ref()); + let name = apply_color(&tool.name, config.tool_name_color.as_ref()); + + match &tool.target { + Some(target) => { + let truncated = truncate_path(target, config.max_target_len); + let target_colored = apply_color(&truncated, config.dim_color.as_ref()); + format!("{} {}: {}", icon, name, target_colored) + } + None => format!("{} {}", icon, name), + } +} + +/// Render a single completed tool stats entry +fn render_completed_tool(stats: &ToolStats, config: &ToolsLineConfig) -> String { + // Determine icon and color based on error status + let (icon, icon_color) = if stats.has_errors() && stats.completed_count == 0 { + // All errors + (icons::ERROR, config.error_icon_color.as_ref()) + } else if stats.has_errors() { + // Mixed: some errors, some completed - show completed icon but could indicate mixed + (icons::COMPLETED, config.completed_icon_color.as_ref()) + } else { + // All completed successfully + (icons::COMPLETED, config.completed_icon_color.as_ref()) + }; + + let icon_colored = apply_color(icon, icon_color); + let name_colored = apply_color(&stats.name, config.tool_name_color.as_ref()); + + // Format count + let count = stats.total(); + if count > 1 { + let count_str = format!("x{}", count); + let count_colored = apply_color(&count_str, config.dim_color.as_ref()); + format!("{} {} {}", icon_colored, name_colored, count_colored) + } else { + format!("{} {}", icon_colored, name_colored) + } +} + +/// Collect statistics for completed tools +fn collect_completed_stats(tools: &[ToolEntry]) -> Vec { + let mut stats_map: HashMap = HashMap::new(); + + for tool in tools { + match tool.status { + ToolStatus::Completed => { + let entry = stats_map + .entry(tool.name.clone()) + .or_insert_with(|| ToolStats::new(tool.name.clone())); + entry.completed_count += 1; + } + ToolStatus::Error => { + let entry = stats_map + .entry(tool.name.clone()) + .or_insert_with(|| ToolStats::new(tool.name.clone())); + entry.error_count += 1; + } + ToolStatus::Running => { + // Skip running tools + } + } + } + + // Convert to vector and sort by total count (descending) + let mut stats: Vec = stats_map.into_values().collect(); + stats.sort_by(|a, b| b.total().cmp(&a.total())); + + stats +} + +/// Render the tools activity line +/// +/// # Arguments +/// * `activity` - Activity data containing tool entries +/// * `config` - Configuration for rendering +/// +/// # Returns +/// `Some(String)` with the rendered line, or `None` if there's no activity to display. +/// +/// # Output Format +/// Running tools are shown first with their targets, followed by completed tools +/// with their call counts, sorted by frequency. +/// +/// Example: `[running_icon] Edit: src/main.rs | [completed_icon] Read x3 | [completed_icon] Grep x2` +pub fn render_tools_line(activity: &ActivityData, config: &ToolsLineConfig) -> Option { + let mut parts: Vec = Vec::new(); + + // 1. Get running tools (most recent first, limited to max_running) + let running_tools: Vec<&ToolEntry> = activity + .tools + .iter() + .filter(|t| t.status == ToolStatus::Running) + .rev() // Most recent first + .take(config.max_running) + .collect(); + + // Render running tools + for tool in running_tools { + parts.push(render_running_tool(tool, config)); + } + + // 2. Collect and render completed tool statistics + let completed_stats = collect_completed_stats(&activity.tools); + + // Take top N completed tools by count + for stats in completed_stats.into_iter().take(config.max_completed) { + parts.push(render_completed_tool(&stats, config)); + } + + // Return None if no parts to display + if parts.is_empty() { + return None; + } + + Some(parts.join(&config.separator)) +} + +/// Render tools line with default configuration +pub fn render_tools_line_default(activity: &ActivityData) -> Option { + render_tools_line(activity, &ToolsLineConfig::default()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::SystemTime; + + fn create_tool(id: &str, name: &str, target: Option<&str>, status: ToolStatus) -> ToolEntry { + let mut tool = ToolEntry::new( + id.to_string(), + name.to_string(), + target.map(String::from), + ); + tool.status = status; + if status != ToolStatus::Running { + tool.end_time = Some(SystemTime::now()); + } + tool + } + + #[test] + fn test_default_config() { + let config = ToolsLineConfig::default(); + assert_eq!(config.max_running, 2); + assert_eq!(config.max_completed, 4); + assert_eq!(config.max_target_len, 20); + assert_eq!(config.separator, " | "); + assert!(config.running_icon_color.is_some()); + assert!(config.completed_icon_color.is_some()); + assert!(config.error_icon_color.is_some()); + assert!(config.tool_name_color.is_some()); + assert!(config.dim_color.is_some()); + } + + #[test] + fn test_render_empty_activity() { + let activity = ActivityData::default(); + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + assert!(result.is_none()); + } + + #[test] + fn test_render_single_running_tool() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool( + "1", + "Edit", + Some("src/main.rs"), + ToolStatus::Running, + )); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + assert!(line.contains(icons::RUNNING)); + assert!(line.contains("Edit")); + assert!(line.contains("src/main.rs")); + } + + #[test] + fn test_render_running_tool_without_target() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Read", None, ToolStatus::Running)); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + assert!(line.contains(icons::RUNNING)); + assert!(line.contains("Read")); + } + + #[test] + fn test_render_completed_tools() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Read", None, ToolStatus::Completed)); + activity.tools.push(create_tool("2", "Read", None, ToolStatus::Completed)); + activity.tools.push(create_tool("3", "Read", None, ToolStatus::Completed)); + activity.tools.push(create_tool("4", "Grep", None, ToolStatus::Completed)); + activity.tools.push(create_tool("5", "Grep", None, ToolStatus::Completed)); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + assert!(line.contains(icons::COMPLETED)); + assert!(line.contains("Read")); + assert!(line.contains("x3")); // Read count + assert!(line.contains("Grep")); + assert!(line.contains("x2")); // Grep count + } + + #[test] + fn test_render_error_tools() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Bash", None, ToolStatus::Error)); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + assert!(line.contains(icons::ERROR)); + assert!(line.contains("Bash")); + } + + #[test] + fn test_render_mixed_status() { + let mut activity = ActivityData::default(); + // Running tool + activity.tools.push(create_tool( + "1", + "Edit", + Some("auth.ts"), + ToolStatus::Running, + )); + // Completed tools + activity.tools.push(create_tool("2", "Read", None, ToolStatus::Completed)); + activity.tools.push(create_tool("3", "Read", None, ToolStatus::Completed)); + activity.tools.push(create_tool("4", "Read", None, ToolStatus::Completed)); + activity.tools.push(create_tool("5", "Grep", None, ToolStatus::Completed)); + activity.tools.push(create_tool("6", "Grep", None, ToolStatus::Completed)); + // Error tool + activity.tools.push(create_tool("7", "Bash", None, ToolStatus::Error)); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Should contain running icon for Edit + assert!(line.contains(icons::RUNNING)); + assert!(line.contains("Edit")); + assert!(line.contains("auth.ts")); + + // Should contain completed icons + assert!(line.contains(icons::COMPLETED)); + assert!(line.contains("Read")); + assert!(line.contains("x3")); + + // Should contain separator + assert!(line.contains(" | ")); + } + + #[test] + fn test_max_running_limit() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Edit", Some("file1.rs"), ToolStatus::Running)); + activity.tools.push(create_tool("2", "Read", Some("file2.rs"), ToolStatus::Running)); + activity.tools.push(create_tool("3", "Grep", Some("pattern"), ToolStatus::Running)); + + let config = ToolsLineConfig { + max_running: 2, + ..Default::default() + }; + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Should only show 2 running tools (most recent: Grep and Read) + // Count occurrences of running icon + let running_count = line.matches(icons::RUNNING).count(); + assert_eq!(running_count, 2); + } + + #[test] + fn test_max_completed_limit() { + let mut activity = ActivityData::default(); + // Add 6 different completed tools + for (i, name) in ["Read", "Write", "Edit", "Grep", "Glob", "Bash"].iter().enumerate() { + activity.tools.push(create_tool( + &format!("{}", i), + name, + None, + ToolStatus::Completed, + )); + } + + let config = ToolsLineConfig { + max_completed: 3, + ..Default::default() + }; + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Should only show 3 completed tools + let completed_count = line.matches(icons::COMPLETED).count(); + assert_eq!(completed_count, 3); + } + + #[test] + fn test_completed_tools_sorted_by_count() { + let mut activity = ActivityData::default(); + // Add tools with different counts + activity.tools.push(create_tool("1", "Read", None, ToolStatus::Completed)); + activity.tools.push(create_tool("2", "Grep", None, ToolStatus::Completed)); + activity.tools.push(create_tool("3", "Grep", None, ToolStatus::Completed)); + activity.tools.push(create_tool("4", "Grep", None, ToolStatus::Completed)); + activity.tools.push(create_tool("5", "Edit", None, ToolStatus::Completed)); + activity.tools.push(create_tool("6", "Edit", None, ToolStatus::Completed)); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Grep (3) should appear before Edit (2) which should appear before Read (1) + let grep_pos = line.find("Grep").unwrap(); + let edit_pos = line.find("Edit").unwrap(); + let read_pos = line.find("Read").unwrap(); + + assert!(grep_pos < edit_pos); + assert!(edit_pos < read_pos); + } + + #[test] + fn test_target_truncation() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool( + "1", + "Edit", + Some("very/long/path/to/some/deeply/nested/file.rs"), + ToolStatus::Running, + )); + + let config = ToolsLineConfig { + max_target_len: 15, + ..Default::default() + }; + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Should contain truncated path + assert!(line.contains(".../file.rs") || line.contains("...")); + } + + #[test] + fn test_custom_separator() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Read", None, ToolStatus::Completed)); + activity.tools.push(create_tool("2", "Write", None, ToolStatus::Completed)); + + let config = ToolsLineConfig { + separator: " :: ".to_string(), + ..Default::default() + }; + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + assert!(line.contains(" :: ")); + } + + #[test] + fn test_single_completed_no_count() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Read", None, ToolStatus::Completed)); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Single tool should not show "x1" + assert!(!line.contains("x1")); + assert!(line.contains("Read")); + } + + #[test] + fn test_render_tools_line_default() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Read", None, ToolStatus::Completed)); + + let result = render_tools_line_default(&activity); + assert!(result.is_some()); + } + + #[test] + fn test_apply_color_color16() { + let text = "test"; + let color = AnsiColor::Color16 { c16: 2 }; // Green + let result = apply_color(text, Some(&color)); + assert!(result.contains("\x1b[32m")); // Green foreground + assert!(result.contains("test")); + assert!(result.contains("\x1b[0m")); // Reset + } + + #[test] + fn test_apply_color_color256() { + let text = "test"; + let color = AnsiColor::Color256 { c256: 208 }; // Orange + let result = apply_color(text, Some(&color)); + assert!(result.contains("\x1b[38;5;208m")); + assert!(result.contains("test")); + } + + #[test] + fn test_apply_color_rgb() { + let text = "test"; + let color = AnsiColor::Rgb { r: 255, g: 128, b: 0 }; + let result = apply_color(text, Some(&color)); + assert!(result.contains("\x1b[38;2;255;128;0m")); + assert!(result.contains("test")); + } + + #[test] + fn test_apply_color_none() { + let text = "test"; + let result = apply_color(text, None); + assert_eq!(result, "test"); + } + + #[test] + fn test_tool_stats() { + let mut stats = ToolStats::new("Read".to_string()); + assert_eq!(stats.total(), 0); + assert!(!stats.has_errors()); + + stats.completed_count = 3; + assert_eq!(stats.total(), 3); + assert!(!stats.has_errors()); + + stats.error_count = 1; + assert_eq!(stats.total(), 4); + assert!(stats.has_errors()); + } + + #[test] + fn test_collect_completed_stats() { + let tools = vec![ + create_tool("1", "Read", None, ToolStatus::Completed), + create_tool("2", "Read", None, ToolStatus::Completed), + create_tool("3", "Read", None, ToolStatus::Error), + create_tool("4", "Write", None, ToolStatus::Completed), + create_tool("5", "Edit", None, ToolStatus::Running), // Should be skipped + ]; + + let stats = collect_completed_stats(&tools); + + // Should have 2 tool types (Read and Write), Edit is running + assert_eq!(stats.len(), 2); + + // Read should be first (3 total: 2 completed + 1 error) + assert_eq!(stats[0].name, "Read"); + assert_eq!(stats[0].completed_count, 2); + assert_eq!(stats[0].error_count, 1); + assert_eq!(stats[0].total(), 3); + + // Write should be second (1 completed) + assert_eq!(stats[1].name, "Write"); + assert_eq!(stats[1].completed_count, 1); + assert_eq!(stats[1].error_count, 0); + } + + #[test] + fn test_bright_color16() { + let text = "test"; + let color = AnsiColor::Color16 { c16: 9 }; // Bright Red + let result = apply_color(text, Some(&color)); + assert!(result.contains("\x1b[91m")); // Bright red (90 + 1) + } + + #[test] + fn test_mixed_completed_and_error_same_tool() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Bash", None, ToolStatus::Completed)); + activity.tools.push(create_tool("2", "Bash", None, ToolStatus::Error)); + activity.tools.push(create_tool("3", "Bash", None, ToolStatus::Completed)); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Should show completed icon (mixed status shows completed) + assert!(line.contains(icons::COMPLETED)); + assert!(line.contains("Bash")); + assert!(line.contains("x3")); // Total count + } + + #[test] + fn test_only_errors_shows_error_icon() { + let mut activity = ActivityData::default(); + activity.tools.push(create_tool("1", "Bash", None, ToolStatus::Error)); + activity.tools.push(create_tool("2", "Bash", None, ToolStatus::Error)); + + let config = ToolsLineConfig::default(); + let result = render_tools_line(&activity, &config); + + assert!(result.is_some()); + let line = result.unwrap(); + + // Should show error icon when all are errors + assert!(line.contains(icons::ERROR)); + assert!(line.contains("Bash")); + assert!(line.contains("x2")); + } +} diff --git a/src/core/activity/transcript_parser.rs b/src/core/activity/transcript_parser.rs new file mode 100644 index 0000000..f48d63f --- /dev/null +++ b/src/core/activity/transcript_parser.rs @@ -0,0 +1,622 @@ +//! Transcript parsing for Claude Code session activity +//! +//! This module parses JSONL transcript files to extract tool calls and agent activity. +//! Based on claude-hud's transcript.ts implementation. + +use super::types::{ActivityData, AgentEntry, AgentStatus, ToolEntry, ToolStatus}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::time::SystemTime; + +/// Maximum number of tools to keep in activity data +const MAX_TOOLS: usize = 20; + +/// Maximum number of agents to keep in activity data +const MAX_AGENTS: usize = 10; + +/// Maximum length for truncated targets (command, url, query) +const MAX_TARGET_LEN: usize = 30; + +/// A single line entry from the transcript JSONL file +#[derive(Debug, Deserialize)] +struct TranscriptLine { + /// ISO 8601 timestamp + timestamp: Option, + /// Message containing content blocks + message: Option, +} + +/// Message structure containing content blocks +#[derive(Debug, Deserialize)] +struct Message { + /// Array of content blocks (tool_use, tool_result, text, etc.) + content: Option>, +} + +/// A content block within a message +#[derive(Debug, Deserialize)] +struct ContentBlock { + /// Block type: "tool_use", "tool_result", "text", etc. + #[serde(rename = "type")] + block_type: String, + /// Unique identifier for tool_use blocks + id: Option, + /// Tool name for tool_use blocks + name: Option, + /// Input parameters for tool_use blocks + input: Option, + /// Reference to tool_use id for tool_result blocks + tool_use_id: Option, + /// Whether the tool result is an error + is_error: Option, +} + +/// Internal state for tracking tools and agents during parsing +struct ParserState { + /// Map of tool_use_id to ToolEntry + tool_map: HashMap, + /// Map of tool_use_id to AgentEntry (for Task tools) + agent_map: HashMap, + /// Session start time from first entry + session_start: Option, +} + +impl ParserState { + fn new() -> Self { + Self { + tool_map: HashMap::new(), + agent_map: HashMap::new(), + session_start: None, + } + } + + /// Convert to final ActivityData, keeping only the last N entries + fn into_activity_data(self) -> ActivityData { + // Convert maps to vectors, sorted by start_time + let mut tools: Vec = self.tool_map.into_values().collect(); + tools.sort_by(|a, b| a.start_time.cmp(&b.start_time)); + + let mut agents: Vec = self.agent_map.into_values().collect(); + agents.sort_by(|a, b| a.start_time.cmp(&b.start_time)); + + // Keep only the last N entries + let tools_len = tools.len(); + let tools = if tools_len > MAX_TOOLS { + tools.into_iter().skip(tools_len - MAX_TOOLS).collect() + } else { + tools + }; + + let agents_len = agents.len(); + let agents = if agents_len > MAX_AGENTS { + agents.into_iter().skip(agents_len - MAX_AGENTS).collect() + } else { + agents + }; + + ActivityData { + tools, + agents, + session_start: self.session_start, + } + } +} + +/// Parse a transcript JSONL file and extract activity data +/// +/// # Arguments +/// * `transcript_path` - Path to the JSONL transcript file +/// +/// # Returns +/// `ActivityData` containing parsed tools and agents. Returns empty data on error. +/// +/// # Example +/// ```ignore +/// let activity = parse_transcript_activity("/path/to/transcript.jsonl"); +/// for tool in activity.running_tools() { +/// println!("Running: {}", tool.name); +/// } +/// ``` +pub fn parse_transcript_activity>(transcript_path: P) -> ActivityData { + let path = transcript_path.as_ref(); + + // Return empty data if path doesn't exist + if !path.exists() { + return ActivityData::default(); + } + + // Open file for reading + let file = match File::open(path) { + Ok(f) => f, + Err(_) => return ActivityData::default(), + }; + + let reader = BufReader::new(file); + let mut state = ParserState::new(); + + // Process each line + for line_result in reader.lines() { + let line = match line_result { + Ok(l) => l, + Err(_) => continue, + }; + + // Skip empty lines + if line.trim().is_empty() { + continue; + } + + // Parse JSON line + let entry: TranscriptLine = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, // Skip malformed lines + }; + + process_entry(&entry, &mut state); + } + + state.into_activity_data() +} + +/// Process a single transcript entry +fn process_entry(entry: &TranscriptLine, state: &mut ParserState) { + // Parse timestamp + let timestamp = parse_timestamp(entry.timestamp.as_deref()); + + // Set session start from first entry with timestamp + if state.session_start.is_none() && entry.timestamp.is_some() { + state.session_start = Some(timestamp); + } + + // Get content blocks + let content = match &entry.message { + Some(msg) => match &msg.content { + Some(c) => c, + None => return, + }, + None => return, + }; + + // Process each content block + for block in content { + process_content_block(block, timestamp, state); + } +} + +/// Process a single content block +fn process_content_block(block: &ContentBlock, timestamp: SystemTime, state: &mut ParserState) { + match block.block_type.as_str() { + "tool_use" => process_tool_use(block, timestamp, state), + "tool_result" => process_tool_result(block, timestamp, state), + _ => {} // Ignore other block types (text, etc.) + } +} + +/// Process a tool_use block +fn process_tool_use(block: &ContentBlock, timestamp: SystemTime, state: &mut ParserState) { + let id = match &block.id { + Some(id) => id.clone(), + None => return, + }; + + let name = match &block.name { + Some(n) => n.clone(), + None => return, + }; + + // Check if this is a Task (agent) tool + if name == "Task" { + let agent_entry = create_agent_entry(&id, &block.input, timestamp); + state.agent_map.insert(id, agent_entry); + } else if name != "TodoWrite" { + // Skip TodoWrite as it's handled separately in the original + // Create regular tool entry + let target = extract_target(&name, &block.input); + let tool_entry = ToolEntry::with_start_time(id.clone(), name, target, timestamp); + state.tool_map.insert(id, tool_entry); + } +} + +/// Process a tool_result block +fn process_tool_result(block: &ContentBlock, timestamp: SystemTime, state: &mut ParserState) { + let tool_use_id = match &block.tool_use_id { + Some(id) => id, + None => return, + }; + + let is_error = block.is_error.unwrap_or(false); + + // Update tool if found + if let Some(tool) = state.tool_map.get_mut(tool_use_id) { + tool.status = if is_error { + ToolStatus::Error + } else { + ToolStatus::Completed + }; + tool.end_time = Some(timestamp); + } + + // Update agent if found + if let Some(agent) = state.agent_map.get_mut(tool_use_id) { + agent.status = AgentStatus::Completed; + agent.end_time = Some(timestamp); + } +} + +/// Create an AgentEntry from Task tool input +fn create_agent_entry(id: &str, input: &Option, timestamp: SystemTime) -> AgentEntry { + let (agent_type, model, description) = match input { + Some(Value::Object(obj)) => { + let agent_type = obj + .get("subagent_type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let model = obj.get("model").and_then(|v| v.as_str()).map(String::from); + + let description = obj + .get("description") + .and_then(|v| v.as_str()) + .map(String::from); + + (agent_type, model, description) + } + _ => ("unknown".to_string(), None, None), + }; + + AgentEntry::with_start_time(id.to_string(), agent_type, model, description, timestamp) +} + +/// Extract target from tool input based on tool name +fn extract_target(tool_name: &str, input: &Option) -> Option { + let obj = match input { + Some(Value::Object(o)) => o, + _ => return None, + }; + + match tool_name { + // File operations: extract file_path or path + "Read" | "Write" | "Edit" => obj + .get("file_path") + .or_else(|| obj.get("path")) + .and_then(|v| v.as_str()) + .map(String::from), + + // Pattern-based tools + "Glob" | "Grep" => obj.get("pattern").and_then(|v| v.as_str()).map(String::from), + + // Bash: first 30 chars of command + "Bash" => obj.get("command").and_then(|v| v.as_str()).map(|cmd| { + if cmd.len() > MAX_TARGET_LEN { + format!("{}...", &cmd[..MAX_TARGET_LEN]) + } else { + cmd.to_string() + } + }), + + // Web tools: url or query, truncated + "WebFetch" => obj.get("url").and_then(|v| v.as_str()).map(|url| { + if url.len() > MAX_TARGET_LEN { + format!("{}...", &url[..MAX_TARGET_LEN]) + } else { + url.to_string() + } + }), + + "WebSearch" => obj.get("query").and_then(|v| v.as_str()).map(|query| { + if query.len() > MAX_TARGET_LEN { + format!("{}...", &query[..MAX_TARGET_LEN]) + } else { + query.to_string() + } + }), + + // Unknown tool: no target + _ => None, + } +} + +/// Parse an ISO 8601 timestamp string to SystemTime +fn parse_timestamp(timestamp_str: Option<&str>) -> SystemTime { + match timestamp_str { + Some(ts) => { + // Try parsing as ISO 8601 with chrono + match DateTime::parse_from_rfc3339(ts) { + Ok(dt) => { + let utc: DateTime = dt.into(); + SystemTime::UNIX_EPOCH + + std::time::Duration::from_secs(utc.timestamp() as u64) + + std::time::Duration::from_nanos(utc.timestamp_subsec_nanos() as u64) + } + Err(_) => { + // Try parsing without timezone (assume UTC) + match chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%dT%H:%M:%S%.f") { + Ok(ndt) => { + let utc = ndt.and_utc(); + SystemTime::UNIX_EPOCH + + std::time::Duration::from_secs(utc.timestamp() as u64) + + std::time::Duration::from_nanos( + utc.timestamp_subsec_nanos() as u64 + ) + } + Err(_) => SystemTime::now(), + } + } + } + } + None => SystemTime::now(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn create_test_transcript(lines: &[&str]) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + for line in lines { + writeln!(file, "{}", line).unwrap(); + } + file.flush().unwrap(); + file + } + + #[test] + fn test_parse_empty_file() { + let file = create_test_transcript(&[]); + let activity = parse_transcript_activity(file.path()); + assert!(activity.tools.is_empty()); + assert!(activity.agents.is_empty()); + assert!(activity.session_start.is_none()); + } + + #[test] + fn test_parse_nonexistent_file() { + let activity = parse_transcript_activity("/nonexistent/path/transcript.jsonl"); + assert!(activity.tools.is_empty()); + assert!(activity.agents.is_empty()); + } + + #[test] + fn test_parse_tool_use() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{"file_path":"/src/main.rs"}}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.tools.len(), 1); + assert_eq!(activity.tools[0].name, "Read"); + assert_eq!(activity.tools[0].target, Some("/src/main.rs".to_string())); + assert_eq!(activity.tools[0].status, ToolStatus::Running); + } + + #[test] + fn test_parse_tool_result() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{"file_path":"/src/main.rs"}}]}}"#, + r#"{"timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"tool_1","is_error":false}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.tools.len(), 1); + assert_eq!(activity.tools[0].status, ToolStatus::Completed); + assert!(activity.tools[0].end_time.is_some()); + } + + #[test] + fn test_parse_tool_error() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{"file_path":"/nonexistent"}}]}}"#, + r#"{"timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"tool_1","is_error":true}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.tools.len(), 1); + assert_eq!(activity.tools[0].status, ToolStatus::Error); + } + + #[test] + fn test_parse_agent_task() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"agent_1","name":"Task","input":{"subagent_type":"Explore","model":"haiku","description":"Finding auth code"}}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert!(activity.tools.is_empty()); + assert_eq!(activity.agents.len(), 1); + assert_eq!(activity.agents[0].agent_type, "Explore"); + assert_eq!(activity.agents[0].model, Some("haiku".to_string())); + assert_eq!( + activity.agents[0].description, + Some("Finding auth code".to_string()) + ); + assert_eq!(activity.agents[0].status, AgentStatus::Running); + } + + #[test] + fn test_parse_agent_completion() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"agent_1","name":"Task","input":{"subagent_type":"Explore"}}]}}"#, + r#"{"timestamp":"2024-01-15T10:02:00Z","message":{"content":[{"type":"tool_result","tool_use_id":"agent_1"}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.agents.len(), 1); + assert_eq!(activity.agents[0].status, AgentStatus::Completed); + assert!(activity.agents[0].end_time.is_some()); + } + + #[test] + fn test_extract_target_bash() { + let input = serde_json::json!({"command": "cargo build --release && cargo test"}); + let target = extract_target("Bash", &Some(input)); + assert_eq!(target, Some("cargo build --release && cargo...".to_string())); + } + + #[test] + fn test_extract_target_short_command() { + let input = serde_json::json!({"command": "ls -la"}); + let target = extract_target("Bash", &Some(input)); + assert_eq!(target, Some("ls -la".to_string())); + } + + #[test] + fn test_extract_target_glob() { + let input = serde_json::json!({"pattern": "**/*.rs"}); + let target = extract_target("Glob", &Some(input)); + assert_eq!(target, Some("**/*.rs".to_string())); + } + + #[test] + fn test_extract_target_web_fetch() { + let input = serde_json::json!({"url": "https://example.com/very/long/path/to/resource"}); + let target = extract_target("WebFetch", &Some(input)); + assert_eq!(target, Some("https://example.com/very/long/...".to_string())); + } + + #[test] + fn test_extract_target_web_search() { + let input = serde_json::json!({"query": "rust async programming best practices guide"}); + let target = extract_target("WebSearch", &Some(input)); + assert_eq!(target, Some("rust async programming best pr...".to_string())); + } + + #[test] + fn test_max_tools_limit() { + let mut lines = Vec::new(); + for i in 0..30 { + lines.push(format!( + r#"{{"timestamp":"2024-01-15T10:00:{:02}Z","message":{{"content":[{{"type":"tool_use","id":"tool_{}","name":"Read","input":{{"file_path":"/file_{}.rs"}}}}]}}}}"#, + i, i, i + )); + } + let lines_ref: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); + let file = create_test_transcript(&lines_ref); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.tools.len(), MAX_TOOLS); + // Should keep the last 20 (indices 10-29) + assert_eq!(activity.tools[0].id, "tool_10"); + assert_eq!(activity.tools[19].id, "tool_29"); + } + + #[test] + fn test_max_agents_limit() { + let mut lines = Vec::new(); + for i in 0..15 { + lines.push(format!( + r#"{{"timestamp":"2024-01-15T10:00:{:02}Z","message":{{"content":[{{"type":"tool_use","id":"agent_{}","name":"Task","input":{{"subagent_type":"Explore"}}}}]}}}}"#, + i, i + )); + } + let lines_ref: Vec<&str> = lines.iter().map(|s| s.as_str()).collect(); + let file = create_test_transcript(&lines_ref); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.agents.len(), MAX_AGENTS); + // Should keep the last 10 (indices 5-14) + assert_eq!(activity.agents[0].id, "agent_5"); + assert_eq!(activity.agents[9].id, "agent_14"); + } + + #[test] + fn test_session_start() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{}}]}}"#, + r#"{"timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"tool_use","id":"tool_2","name":"Write","input":{}}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert!(activity.session_start.is_some()); + } + + #[test] + fn test_skip_malformed_lines() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{}}]}}"#, + r#"not valid json"#, + r#"{"timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_use","id":"tool_2","name":"Write","input":{}}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.tools.len(), 2); + } + + #[test] + fn test_skip_empty_lines() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"tool_1","name":"Read","input":{}}]}}"#, + "", + " ", + r#"{"timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_use","id":"tool_2","name":"Write","input":{}}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.tools.len(), 2); + } + + #[test] + fn test_todo_write_ignored() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"todo_1","name":"TodoWrite","input":{"todos":[]}}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert!(activity.tools.is_empty()); + assert!(activity.agents.is_empty()); + } + + #[test] + fn test_mixed_content_blocks() { + let lines = [ + r#"{"timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"text","text":"Hello"},{"type":"tool_use","id":"tool_1","name":"Read","input":{"file_path":"/test.rs"}},{"type":"text","text":"World"}]}}"#, + ]; + let file = create_test_transcript(&lines); + let activity = parse_transcript_activity(file.path()); + + assert_eq!(activity.tools.len(), 1); + assert_eq!(activity.tools[0].name, "Read"); + } + + #[test] + fn test_parse_timestamp_rfc3339() { + let ts = parse_timestamp(Some("2024-01-15T10:30:45Z")); + // Should not be the current time (which would indicate parse failure) + let now = SystemTime::now(); + assert!(ts < now); + } + + #[test] + fn test_parse_timestamp_with_offset() { + let ts = parse_timestamp(Some("2024-01-15T10:30:45+05:00")); + let now = SystemTime::now(); + assert!(ts < now); + } + + #[test] + fn test_parse_timestamp_none() { + let before = SystemTime::now(); + let ts = parse_timestamp(None); + let after = SystemTime::now(); + // Should be approximately now + assert!(ts >= before); + assert!(ts <= after); + } +} diff --git a/src/core/activity/types.rs b/src/core/activity/types.rs new file mode 100644 index 0000000..db18204 --- /dev/null +++ b/src/core/activity/types.rs @@ -0,0 +1,407 @@ +//! Activity tracking data structures +//! +//! This module defines the core data structures for tracking tool calls, +//! agent status, and configuration counts. + +use serde::{Deserialize, Serialize}; +use std::time::{Duration, SystemTime}; + +/// Tool execution status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolStatus { + Running, + Completed, + Error, +} + +/// Agent execution status +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStatus { + Running, + Completed, +} + +/// Represents a single tool invocation +#[derive(Debug, Clone)] +pub struct ToolEntry { + /// Unique identifier from tool_use block + pub id: String, + /// Tool name (e.g., "Read", "Edit", "Bash") + pub name: String, + /// Optional target (file path, pattern, command preview) + pub target: Option, + /// Current execution status + pub status: ToolStatus, + /// When the tool was invoked + pub start_time: SystemTime, + /// When the tool completed (if finished) + pub end_time: Option, +} + +impl ToolEntry { + /// Create a new running tool entry + pub fn new(id: String, name: String, target: Option) -> Self { + Self { + id, + name, + target, + status: ToolStatus::Running, + start_time: SystemTime::now(), + end_time: None, + } + } + + /// Create a new tool entry with a specific start time + pub fn with_start_time(id: String, name: String, target: Option, start_time: SystemTime) -> Self { + Self { + id, + name, + target, + status: ToolStatus::Running, + start_time, + end_time: None, + } + } + + /// Mark the tool as completed + pub fn complete(&mut self, is_error: bool) { + self.status = if is_error { + ToolStatus::Error + } else { + ToolStatus::Completed + }; + self.end_time = Some(SystemTime::now()); + } + + /// Mark the tool as completed with a specific end time + pub fn complete_at(&mut self, is_error: bool, end_time: SystemTime) { + self.status = if is_error { + ToolStatus::Error + } else { + ToolStatus::Completed + }; + self.end_time = Some(end_time); + } + + /// Get elapsed time since start + pub fn elapsed(&self) -> Duration { + let end = self.end_time.unwrap_or_else(SystemTime::now); + end.duration_since(self.start_time).unwrap_or_default() + } +} + +/// Represents an agent (subagent) invocation +#[derive(Debug, Clone)] +pub struct AgentEntry { + /// Unique identifier from Task tool_use block + pub id: String, + /// Agent type (e.g., "Explore", "fix", "Plan") + pub agent_type: String, + /// Model used by the agent (e.g., "haiku", "sonnet") + pub model: Option, + /// Task description + pub description: Option, + /// Current execution status + pub status: AgentStatus, + /// When the agent was started + pub start_time: SystemTime, + /// When the agent completed (if finished) + pub end_time: Option, +} + +impl AgentEntry { + /// Create a new running agent entry + pub fn new( + id: String, + agent_type: String, + model: Option, + description: Option, + ) -> Self { + Self { + id, + agent_type, + model, + description, + status: AgentStatus::Running, + start_time: SystemTime::now(), + end_time: None, + } + } + + /// Create a new agent entry with a specific start time + pub fn with_start_time( + id: String, + agent_type: String, + model: Option, + description: Option, + start_time: SystemTime, + ) -> Self { + Self { + id, + agent_type, + model, + description, + status: AgentStatus::Running, + start_time, + end_time: None, + } + } + + /// Mark the agent as completed + pub fn complete(&mut self) { + self.status = AgentStatus::Completed; + self.end_time = Some(SystemTime::now()); + } + + /// Mark the agent as completed with a specific end time + pub fn complete_at(&mut self, end_time: SystemTime) { + self.status = AgentStatus::Completed; + self.end_time = Some(end_time); + } + + /// Get elapsed time since start + pub fn elapsed(&self) -> Duration { + let end = self.end_time.unwrap_or_else(SystemTime::now); + end.duration_since(self.start_time).unwrap_or_default() + } +} + +/// Configuration counts from Claude Code environment +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConfigCounts { + /// Number of CLAUDE.md files found + pub claude_md_count: u32, + /// Number of rule files (.md in rules directories) + pub rules_count: u32, + /// Number of MCP servers configured + pub mcp_count: u32, + /// Number of hooks configured + pub hooks_count: u32, +} + +impl ConfigCounts { + /// Check if any configs are present + pub fn has_any(&self) -> bool { + self.claude_md_count > 0 + || self.rules_count > 0 + || self.mcp_count > 0 + || self.hooks_count > 0 + } + + /// Get total count of all config items + pub fn total(&self) -> u32 { + self.claude_md_count + self.rules_count + self.mcp_count + self.hooks_count + } +} + +/// Aggregated activity data from transcript parsing +#[derive(Debug, Clone, Default)] +pub struct ActivityData { + /// All tracked tools (limited to last N) + pub tools: Vec, + /// All tracked agents (limited to last N) + pub agents: Vec, + /// Session start time (from first transcript entry) + pub session_start: Option, +} + +impl ActivityData { + /// Get currently running tools + pub fn running_tools(&self) -> Vec<&ToolEntry> { + self.tools + .iter() + .filter(|t| t.status == ToolStatus::Running) + .collect() + } + + /// Get completed tools (including errors) + pub fn completed_tools(&self) -> Vec<&ToolEntry> { + self.tools + .iter() + .filter(|t| t.status != ToolStatus::Running) + .collect() + } + + /// Get currently running agents + pub fn running_agents(&self) -> Vec<&AgentEntry> { + self.agents + .iter() + .filter(|a| a.status == AgentStatus::Running) + .collect() + } + + /// Get completed agents + pub fn completed_agents(&self) -> Vec<&AgentEntry> { + self.agents + .iter() + .filter(|a| a.status == AgentStatus::Completed) + .collect() + } + + /// Check if there's any activity to display + pub fn has_activity(&self) -> bool { + !self.tools.is_empty() || !self.agents.is_empty() + } +} + +/// Format a duration for display +pub fn format_duration(duration: Duration) -> String { + let secs = duration.as_secs(); + + if secs < 1 { + return "<1s".to_string(); + } + + if secs < 60 { + return format!("{}s", secs); + } + + let mins = secs / 60; + let remaining_secs = secs % 60; + + if remaining_secs == 0 { + format!("{}m", mins) + } else { + format!("{}m {}s", mins, remaining_secs) + } +} + +/// Truncate a string to max length with ellipsis +pub fn truncate_string(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else if max_len <= 3 { + "...".to_string() + } else { + format!("{}...", &s[..max_len - 3]) + } +} + +/// Truncate a file path intelligently, showing .../filename format +pub fn truncate_path(path: &str, max_len: usize) -> String { + // Normalize Windows backslashes to forward slashes + let normalized = path.replace('\\', "/"); + + if normalized.len() <= max_len { + return normalized; + } + + // Extract filename + let parts: Vec<&str> = normalized.split('/').collect(); + let filename = parts.last().unwrap_or(&path); + + if filename.len() >= max_len { + return truncate_string(filename, max_len); + } + + // Check if we can fit ".../filename" + let prefix_len = 4; // ".../" + if filename.len() + prefix_len <= max_len { + format!(".../{}", filename) + } else { + truncate_string(filename, max_len) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(Duration::from_millis(500)), "<1s"); + assert_eq!(format_duration(Duration::from_secs(0)), "<1s"); + assert_eq!(format_duration(Duration::from_secs(1)), "1s"); + assert_eq!(format_duration(Duration::from_secs(30)), "30s"); + assert_eq!(format_duration(Duration::from_secs(59)), "59s"); + assert_eq!(format_duration(Duration::from_secs(60)), "1m"); + assert_eq!(format_duration(Duration::from_secs(90)), "1m 30s"); + assert_eq!(format_duration(Duration::from_secs(125)), "2m 5s"); + assert_eq!(format_duration(Duration::from_secs(120)), "2m"); + } + + #[test] + fn test_truncate_string() { + assert_eq!(truncate_string("hello", 10), "hello"); + assert_eq!(truncate_string("hello world", 8), "hello..."); + assert_eq!(truncate_string("hi", 2), "hi"); + assert_eq!(truncate_string("hello", 3), "..."); + assert_eq!(truncate_string("hello", 5), "hello"); + } + + #[test] + fn test_truncate_path() { + assert_eq!(truncate_path("src/main.rs", 20), "src/main.rs"); + assert_eq!(truncate_path("very/long/path/to/file.rs", 15), ".../file.rs"); + assert_eq!(truncate_path("C:\\Users\\test\\file.rs", 15), ".../file.rs"); + assert_eq!(truncate_path("short.rs", 20), "short.rs"); + } + + #[test] + fn test_config_counts() { + let empty = ConfigCounts::default(); + assert!(!empty.has_any()); + assert_eq!(empty.total(), 0); + + let counts = ConfigCounts { + claude_md_count: 2, + rules_count: 5, + mcp_count: 3, + hooks_count: 1, + }; + assert!(counts.has_any()); + assert_eq!(counts.total(), 11); + } + + #[test] + fn test_tool_entry() { + let mut tool = ToolEntry::new( + "123".to_string(), + "Read".to_string(), + Some("file.rs".to_string()), + ); + assert_eq!(tool.status, ToolStatus::Running); + assert!(tool.end_time.is_none()); + + tool.complete(false); + assert_eq!(tool.status, ToolStatus::Completed); + assert!(tool.end_time.is_some()); + } + + #[test] + fn test_agent_entry() { + let mut agent = AgentEntry::new( + "456".to_string(), + "Explore".to_string(), + Some("haiku".to_string()), + Some("Finding code".to_string()), + ); + assert_eq!(agent.status, AgentStatus::Running); + assert!(agent.end_time.is_none()); + + agent.complete(); + assert_eq!(agent.status, AgentStatus::Completed); + assert!(agent.end_time.is_some()); + } + + #[test] + fn test_activity_data() { + let mut activity = ActivityData::default(); + assert!(!activity.has_activity()); + + activity.tools.push(ToolEntry::new( + "1".to_string(), + "Read".to_string(), + None, + )); + assert!(activity.has_activity()); + assert_eq!(activity.running_tools().len(), 1); + assert_eq!(activity.completed_tools().len(), 0); + + activity.tools[0].complete(false); + assert_eq!(activity.running_tools().len(), 0); + assert_eq!(activity.completed_tools().len(), 1); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index bac1aec..484e0f4 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,7 @@ +pub mod activity; +pub mod multiline; pub mod segments; pub mod statusline; +pub use multiline::{render_multiline, MultilineConfig, MultilineRenderer}; pub use statusline::{collect_all_segments, StatusLineGenerator}; diff --git a/src/core/multiline.rs b/src/core/multiline.rs new file mode 100644 index 0000000..5abd266 --- /dev/null +++ b/src/core/multiline.rs @@ -0,0 +1,601 @@ +//! Multi-line output coordinator for Claude Code statusline +//! +//! This module provides functionality for rendering multi-line statusline output, +//! combining the main statusline with optional tool activity and agent status lines. +//! +//! # Output Structure +//! +//! The multi-line renderer can produce up to 3 lines: +//! +//! 1. **Main statusline**: Model, directory, git, context window, usage, cost, etc. +//! 2. **Tool activity line**: Currently running and recently completed tools +//! 3. **Agent status line**: Running and completed subagents +//! +//! # Example Usage +//! +//! ```ignore +//! use ccometixline::config::{Config, InputData}; +//! use ccometixline::core::multiline::{MultilineConfig, MultilineRenderer}; +//! +//! let config = Config::default(); +//! let multiline_config = MultilineConfig::default(); +//! let renderer = MultilineRenderer::new(config, multiline_config); +//! +//! let input = InputData { +//! transcript_path: "/path/to/transcript.jsonl".to_string(), +//! ..Default::default() +//! }; +//! +//! // Render all lines +//! let lines = renderer.render(&input); +//! for line in lines { +//! println!("{}", line); +//! } +//! ``` + +use crate::config::{Config, InputData}; + +use super::activity::{ + parse_transcript_activity, render_agents_line, render_tools_line, AgentsLineConfig, + ToolsLineConfig, +}; +use super::statusline::{collect_all_segments, StatusLineGenerator}; + +/// Configuration for multi-line statusline rendering +#[derive(Debug, Clone)] +pub struct MultilineConfig { + /// Whether to show the tools activity line (Line 2) + pub show_tools: bool, + /// Whether to show the agents status line (Line 3) + pub show_agents: bool, + /// Configuration for tools line rendering + pub tools_config: ToolsLineConfig, + /// Configuration for agents line rendering + pub agents_config: AgentsLineConfig, +} + +impl Default for MultilineConfig { + fn default() -> Self { + Self { + show_tools: true, + show_agents: true, + tools_config: ToolsLineConfig::default(), + agents_config: AgentsLineConfig::default(), + } + } +} + +impl MultilineConfig { + /// Create a new MultilineConfig with default settings + pub fn new() -> Self { + Self::default() + } + + /// Create a config that only shows the main statusline + pub fn main_only() -> Self { + Self { + show_tools: false, + show_agents: false, + tools_config: ToolsLineConfig::default(), + agents_config: AgentsLineConfig::default(), + } + } + + /// Create a config that shows tools but not agents + pub fn with_tools_only() -> Self { + Self { + show_tools: true, + show_agents: false, + tools_config: ToolsLineConfig::default(), + agents_config: AgentsLineConfig::default(), + } + } + + /// Create a config that shows agents but not tools + pub fn with_agents_only() -> Self { + Self { + show_tools: false, + show_agents: true, + tools_config: ToolsLineConfig::default(), + agents_config: AgentsLineConfig::default(), + } + } + + /// Builder method to set show_tools + pub fn show_tools(mut self, show: bool) -> Self { + self.show_tools = show; + self + } + + /// Builder method to set show_agents + pub fn show_agents(mut self, show: bool) -> Self { + self.show_agents = show; + self + } + + /// Builder method to set tools_config + pub fn tools_config(mut self, config: ToolsLineConfig) -> Self { + self.tools_config = config; + self + } + + /// Builder method to set agents_config + pub fn agents_config(mut self, config: AgentsLineConfig) -> Self { + self.agents_config = config; + self + } +} + +/// Multi-line statusline renderer +/// +/// Combines the main statusline with optional tool activity and agent status lines. +pub struct MultilineRenderer { + config: Config, + multiline_config: MultilineConfig, +} + +impl MultilineRenderer { + /// Create a new MultilineRenderer + /// + /// # Arguments + /// + /// * `config` - Main statusline configuration + /// * `multiline_config` - Multi-line specific configuration + pub fn new(config: Config, multiline_config: MultilineConfig) -> Self { + Self { + config, + multiline_config, + } + } + + /// Render all statusline lines + /// + /// # Arguments + /// + /// * `input` - Input data for statusline generation + /// + /// # Returns + /// + /// A vector of strings, one for each line to display. + /// The vector will contain: + /// - Line 1: Main statusline (always present) + /// - Line 2: Tool activity line (if enabled and has data) + /// - Line 3: Agent status line (if enabled and has data) + pub fn render(&self, input: &InputData) -> Vec { + let mut lines = Vec::new(); + + // Line 1: Main statusline + let segments = collect_all_segments(&self.config, input); + let generator = StatusLineGenerator::new(self.config.clone()); + lines.push(generator.generate(segments)); + + // Parse transcript if path is not empty + if !input.transcript_path.is_empty() { + let activity = parse_transcript_activity(&input.transcript_path); + + // Line 2: Tool Activity (if enabled and has data) + if self.multiline_config.show_tools { + if let Some(tools_line) = + render_tools_line(&activity, &self.multiline_config.tools_config) + { + lines.push(tools_line); + } + } + + // Line 3: Agent Status (if enabled and has data) + if self.multiline_config.show_agents { + if let Some(agents_line) = + render_agents_line(&activity, &self.multiline_config.agents_config) + { + lines.push(agents_line); + } + } + } + + lines + } + + /// Render all lines and print to stdout + /// + /// Convenience method that renders all lines and prints each one. + pub fn render_and_print(&self, input: &InputData) { + for line in self.render(input) { + println!("{}", line); + } + } + + /// Get a reference to the main config + pub fn config(&self) -> &Config { + &self.config + } + + /// Get a reference to the multiline config + pub fn multiline_config(&self) -> &MultilineConfig { + &self.multiline_config + } + + /// Get the number of lines that would be rendered + /// + /// This is useful for terminal UI layout calculations. + /// Returns the actual number of lines based on current configuration + /// and whether there's activity data to display. + pub fn line_count(&self, input: &InputData) -> usize { + self.render(input).len() + } + + /// Get the maximum possible number of lines + /// + /// Returns 3 if both tools and agents are enabled, 2 if only one is enabled, + /// or 1 if neither is enabled. + pub fn max_line_count(&self) -> usize { + let mut count = 1; // Main statusline always present + if self.multiline_config.show_tools { + count += 1; + } + if self.multiline_config.show_agents { + count += 1; + } + count + } +} + +/// Convenience function to render multi-line statusline +/// +/// This is a simpler interface for one-off rendering without creating +/// a MultilineRenderer instance. +/// +/// # Arguments +/// +/// * `config` - Main statusline configuration +/// * `multiline_config` - Multi-line specific configuration +/// * `input` - Input data for statusline generation +/// +/// # Returns +/// +/// A vector of strings, one for each line to display. +/// +/// # Example +/// +/// ```ignore +/// use ccometixline::config::{Config, InputData}; +/// use ccometixline::core::multiline::{render_multiline, MultilineConfig}; +/// +/// let config = Config::default(); +/// let multiline_config = MultilineConfig::default(); +/// let input = InputData::default(); +/// +/// let lines = render_multiline(&config, &multiline_config, &input); +/// for line in lines { +/// println!("{}", line); +/// } +/// ``` +pub fn render_multiline( + config: &Config, + multiline_config: &MultilineConfig, + input: &InputData, +) -> Vec { + let renderer = MultilineRenderer::new(config.clone(), multiline_config.clone()); + renderer.render(input) +} + +/// Render multi-line statusline and print to stdout +/// +/// Convenience function that renders and prints all lines. +pub fn render_multiline_and_print( + config: &Config, + multiline_config: &MultilineConfig, + input: &InputData, +) { + for line in render_multiline(config, multiline_config, input) { + println!("{}", line); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::InputData; + + #[test] + fn test_multiline_config_default() { + let config = MultilineConfig::default(); + assert!(config.show_tools); + assert!(config.show_agents); + } + + #[test] + fn test_multiline_config_new() { + let config = MultilineConfig::new(); + assert!(config.show_tools); + assert!(config.show_agents); + } + + #[test] + fn test_multiline_config_main_only() { + let config = MultilineConfig::main_only(); + assert!(!config.show_tools); + assert!(!config.show_agents); + } + + #[test] + fn test_multiline_config_with_tools_only() { + let config = MultilineConfig::with_tools_only(); + assert!(config.show_tools); + assert!(!config.show_agents); + } + + #[test] + fn test_multiline_config_with_agents_only() { + let config = MultilineConfig::with_agents_only(); + assert!(!config.show_tools); + assert!(config.show_agents); + } + + #[test] + fn test_multiline_config_builder() { + let config = MultilineConfig::default() + .show_tools(false) + .show_agents(true); + + assert!(!config.show_tools); + assert!(config.show_agents); + } + + #[test] + fn test_multiline_config_builder_with_configs() { + let tools_config = ToolsLineConfig { + max_running: 5, + ..Default::default() + }; + let agents_config = AgentsLineConfig { + max_agents: 5, + ..Default::default() + }; + + let config = MultilineConfig::default() + .tools_config(tools_config.clone()) + .agents_config(agents_config.clone()); + + assert_eq!(config.tools_config.max_running, 5); + assert_eq!(config.agents_config.max_agents, 5); + } + + #[test] + fn test_multiline_renderer_new() { + let config = Config::default(); + let multiline_config = MultilineConfig::default(); + let renderer = MultilineRenderer::new(config.clone(), multiline_config.clone()); + + assert!(renderer.multiline_config().show_tools); + assert!(renderer.multiline_config().show_agents); + } + + #[test] + fn test_multiline_renderer_render_no_transcript() { + let config = Config::default(); + let multiline_config = MultilineConfig::default(); + let renderer = MultilineRenderer::new(config, multiline_config); + + let input = InputData::default(); + let lines = renderer.render(&input); + + // Should have at least the main statusline + assert!(!lines.is_empty()); + // Without transcript, should only have main line + assert_eq!(lines.len(), 1); + } + + #[test] + fn test_multiline_renderer_render_with_nonexistent_transcript() { + let config = Config::default(); + let multiline_config = MultilineConfig::default(); + let renderer = MultilineRenderer::new(config, multiline_config); + + let input = InputData { + transcript_path: "/nonexistent/path/transcript.jsonl".to_string(), + ..Default::default() + }; + let lines = renderer.render(&input); + + // Should have main statusline, but no activity lines (empty transcript) + assert!(!lines.is_empty()); + assert_eq!(lines.len(), 1); + } + + #[test] + fn test_multiline_renderer_max_line_count() { + let config = Config::default(); + + // All enabled + let multiline_config = MultilineConfig::default(); + let renderer = MultilineRenderer::new(config.clone(), multiline_config); + assert_eq!(renderer.max_line_count(), 3); + + // Tools only + let multiline_config = MultilineConfig::with_tools_only(); + let renderer = MultilineRenderer::new(config.clone(), multiline_config); + assert_eq!(renderer.max_line_count(), 2); + + // Agents only + let multiline_config = MultilineConfig::with_agents_only(); + let renderer = MultilineRenderer::new(config.clone(), multiline_config); + assert_eq!(renderer.max_line_count(), 2); + + // Main only + let multiline_config = MultilineConfig::main_only(); + let renderer = MultilineRenderer::new(config.clone(), multiline_config); + assert_eq!(renderer.max_line_count(), 1); + } + + #[test] + fn test_multiline_renderer_line_count() { + let config = Config::default(); + let multiline_config = MultilineConfig::default(); + let renderer = MultilineRenderer::new(config, multiline_config); + + let input = InputData::default(); + let count = renderer.line_count(&input); + + // Without transcript, should be 1 (main line only) + assert_eq!(count, 1); + } + + #[test] + fn test_render_multiline_function() { + let config = Config::default(); + let multiline_config = MultilineConfig::default(); + let input = InputData::default(); + + let lines = render_multiline(&config, &multiline_config, &input); + + // Should have at least the main statusline + assert!(!lines.is_empty()); + } + + #[test] + fn test_multiline_renderer_config_accessors() { + let config = Config::default(); + let multiline_config = MultilineConfig::default(); + let renderer = MultilineRenderer::new(config.clone(), multiline_config.clone()); + + // Verify we can access configs + let _ = renderer.config(); + let mc = renderer.multiline_config(); + assert!(mc.show_tools); + assert!(mc.show_agents); + } + + #[test] + fn test_multiline_config_tools_config_builder() { + let custom_tools = ToolsLineConfig { + max_running: 10, + max_completed: 8, + separator: " :: ".to_string(), + ..Default::default() + }; + + let config = MultilineConfig::default().tools_config(custom_tools); + + assert_eq!(config.tools_config.max_running, 10); + assert_eq!(config.tools_config.max_completed, 8); + assert_eq!(config.tools_config.separator, " :: "); + } + + #[test] + fn test_multiline_config_agents_config_builder() { + let custom_agents = AgentsLineConfig { + max_agents: 5, + max_description_len: 50, + separator: " -> ".to_string(), + ..Default::default() + }; + + let config = MultilineConfig::default().agents_config(custom_agents); + + assert_eq!(config.agents_config.max_agents, 5); + assert_eq!(config.agents_config.max_description_len, 50); + assert_eq!(config.agents_config.separator, " -> "); + } + + // Integration test with temporary transcript file + #[test] + fn test_multiline_renderer_with_transcript() { + use std::io::Write; + use tempfile::NamedTempFile; + + // Create a temporary transcript file with tool activity + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{"timestamp":"2024-01-15T10:00:00Z","message":{{"content":[{{"type":"tool_use","id":"tool_1","name":"Read","input":{{"file_path":"/src/main.rs"}}}}]}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"2024-01-15T10:00:01Z","message":{{"content":[{{"type":"tool_result","tool_use_id":"tool_1","is_error":false}}]}}}}"# + ) + .unwrap(); + file.flush().unwrap(); + + let config = Config::default(); + let multiline_config = MultilineConfig::default(); + let renderer = MultilineRenderer::new(config, multiline_config); + + let input = InputData { + transcript_path: file.path().to_string_lossy().to_string(), + ..Default::default() + }; + + let lines = renderer.render(&input); + + // Should have main line + tools line (completed tool) + assert!(lines.len() >= 1); + // The tools line should be present if there's activity + if lines.len() > 1 { + // Tools line should contain the tool name + assert!(lines[1].contains("Read")); + } + } + + #[test] + fn test_multiline_renderer_disabled_tools() { + use std::io::Write; + use tempfile::NamedTempFile; + + // Create a temporary transcript file with tool activity + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{"timestamp":"2024-01-15T10:00:00Z","message":{{"content":[{{"type":"tool_use","id":"tool_1","name":"Read","input":{{}}}}]}}}}"# + ) + .unwrap(); + file.flush().unwrap(); + + let config = Config::default(); + let multiline_config = MultilineConfig::main_only(); // Disable tools and agents + let renderer = MultilineRenderer::new(config, multiline_config); + + let input = InputData { + transcript_path: file.path().to_string_lossy().to_string(), + ..Default::default() + }; + + let lines = renderer.render(&input); + + // Should only have main line even with transcript + assert_eq!(lines.len(), 1); + } + + #[test] + fn test_multiline_renderer_with_agent_activity() { + use std::io::Write; + use tempfile::NamedTempFile; + + // Create a temporary transcript file with agent activity + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#"{{"timestamp":"2024-01-15T10:00:00Z","message":{{"content":[{{"type":"tool_use","id":"agent_1","name":"Task","input":{{"subagent_type":"Explore","model":"haiku","description":"Finding code"}}}}]}}}}"# + ) + .unwrap(); + file.flush().unwrap(); + + let config = Config::default(); + let multiline_config = MultilineConfig::with_agents_only(); + let renderer = MultilineRenderer::new(config, multiline_config); + + let input = InputData { + transcript_path: file.path().to_string_lossy().to_string(), + ..Default::default() + }; + + let lines = renderer.render(&input); + + // Should have main line + agents line + assert!(lines.len() >= 1); + if lines.len() > 1 { + // Agents line should contain the agent type + assert!(lines[1].contains("Explore")); + } + } +} diff --git a/src/core/segments/config_counts.rs b/src/core/segments/config_counts.rs new file mode 100644 index 0000000..e76395a --- /dev/null +++ b/src/core/segments/config_counts.rs @@ -0,0 +1,275 @@ +//! Config Counts Segment +//! +//! Displays counts of Claude Code configuration items: +//! - CLAUDE.md files +//! - Rule files (.md in rules directories) +//! - MCP servers configured +//! - Hooks configured +//! +//! Output format: "2 CLAUDE.md | 3 rules | 5 MCPs | 2 hooks" +//! Only shows non-zero counts. Returns None if no configs found. + +use super::{Segment, SegmentData}; +use crate::config::{InputData, SegmentId}; +use crate::core::activity::cache::get_config_counts_cached; +use crate::core::activity::types::ConfigCounts; +use std::collections::HashMap; + +/// Segment that displays configuration counts from Claude Code environment +pub struct ConfigCountsSegment { + /// Optional cache TTL in seconds. If None, uses default (60 seconds) + cache_ttl: Option, +} + +impl Default for ConfigCountsSegment { + fn default() -> Self { + Self::new() + } +} + +impl ConfigCountsSegment { + /// Create a new ConfigCountsSegment with default cache TTL + pub fn new() -> Self { + Self { cache_ttl: None } + } + + /// Create a new ConfigCountsSegment with a custom cache TTL + /// + /// # Arguments + /// + /// * `ttl_secs` - Cache TTL in seconds + pub fn with_cache_ttl(ttl_secs: u64) -> Self { + Self { + cache_ttl: Some(ttl_secs), + } + } +} + +impl Segment for ConfigCountsSegment { + fn collect(&self, input: &InputData) -> Option { + let counts = + get_config_counts_cached(Some(&input.workspace.current_dir), self.cache_ttl); + + if !counts.has_any() { + return None; + } + + // Build display string: "2 CLAUDE.md | 3 rules | 5 MCPs | 2 hooks" + let parts = build_display_parts(&counts); + + let mut metadata = HashMap::new(); + metadata.insert( + "claude_md_count".to_string(), + counts.claude_md_count.to_string(), + ); + metadata.insert("rules_count".to_string(), counts.rules_count.to_string()); + metadata.insert("mcp_count".to_string(), counts.mcp_count.to_string()); + metadata.insert("hooks_count".to_string(), counts.hooks_count.to_string()); + metadata.insert("total".to_string(), counts.total().to_string()); + + Some(SegmentData { + primary: parts.join(" | "), + secondary: String::new(), + metadata, + }) + } + + fn id(&self) -> SegmentId { + SegmentId::ConfigCounts + } +} + +/// Build display parts from config counts +/// +/// Only includes non-zero counts in the output. +/// +/// # Arguments +/// +/// * `counts` - The configuration counts to format +/// +/// # Returns +/// +/// A vector of formatted strings for each non-zero count +/// +/// # Examples +/// +/// ```ignore +/// let counts = ConfigCounts { +/// claude_md_count: 2, +/// rules_count: 3, +/// mcp_count: 0, +/// hooks_count: 1, +/// }; +/// let parts = build_display_parts(&counts); +/// // parts = ["2 CLAUDE.md", "3 rules", "1 hook"] +/// ``` +fn build_display_parts(counts: &ConfigCounts) -> Vec { + let mut parts = Vec::new(); + + if counts.claude_md_count > 0 { + parts.push(format!("{} CLAUDE.md", counts.claude_md_count)); + } + + if counts.rules_count > 0 { + // Use singular/plural form + let label = if counts.rules_count == 1 { + "rule" + } else { + "rules" + }; + parts.push(format!("{} {}", counts.rules_count, label)); + } + + if counts.mcp_count > 0 { + // Use singular/plural form + let label = if counts.mcp_count == 1 { "MCP" } else { "MCPs" }; + parts.push(format!("{} {}", counts.mcp_count, label)); + } + + if counts.hooks_count > 0 { + // Use singular/plural form + let label = if counts.hooks_count == 1 { + "hook" + } else { + "hooks" + }; + parts.push(format!("{} {}", counts.hooks_count, label)); + } + + parts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_display_parts_all_counts() { + let counts = ConfigCounts { + claude_md_count: 2, + rules_count: 3, + mcp_count: 5, + hooks_count: 2, + }; + + let parts = build_display_parts(&counts); + + assert_eq!(parts.len(), 4); + assert_eq!(parts[0], "2 CLAUDE.md"); + assert_eq!(parts[1], "3 rules"); + assert_eq!(parts[2], "5 MCPs"); + assert_eq!(parts[3], "2 hooks"); + } + + #[test] + fn test_build_display_parts_single_counts() { + let counts = ConfigCounts { + claude_md_count: 1, + rules_count: 1, + mcp_count: 1, + hooks_count: 1, + }; + + let parts = build_display_parts(&counts); + + assert_eq!(parts.len(), 4); + assert_eq!(parts[0], "1 CLAUDE.md"); + assert_eq!(parts[1], "1 rule"); + assert_eq!(parts[2], "1 MCP"); + assert_eq!(parts[3], "1 hook"); + } + + #[test] + fn test_build_display_parts_partial_counts() { + let counts = ConfigCounts { + claude_md_count: 2, + rules_count: 0, + mcp_count: 3, + hooks_count: 0, + }; + + let parts = build_display_parts(&counts); + + assert_eq!(parts.len(), 2); + assert_eq!(parts[0], "2 CLAUDE.md"); + assert_eq!(parts[1], "3 MCPs"); + } + + #[test] + fn test_build_display_parts_only_claude_md() { + let counts = ConfigCounts { + claude_md_count: 1, + rules_count: 0, + mcp_count: 0, + hooks_count: 0, + }; + + let parts = build_display_parts(&counts); + + assert_eq!(parts.len(), 1); + assert_eq!(parts[0], "1 CLAUDE.md"); + } + + #[test] + fn test_build_display_parts_empty() { + let counts = ConfigCounts::default(); + + let parts = build_display_parts(&counts); + + assert!(parts.is_empty()); + } + + #[test] + fn test_config_counts_segment_new() { + let segment = ConfigCountsSegment::new(); + assert!(segment.cache_ttl.is_none()); + } + + #[test] + fn test_config_counts_segment_with_cache_ttl() { + let segment = ConfigCountsSegment::with_cache_ttl(30); + assert_eq!(segment.cache_ttl, Some(30)); + } + + #[test] + fn test_config_counts_segment_default() { + let segment = ConfigCountsSegment::default(); + assert!(segment.cache_ttl.is_none()); + } + + #[test] + fn test_config_counts_segment_id() { + let segment = ConfigCountsSegment::new(); + assert_eq!(segment.id(), SegmentId::ConfigCounts); + } + + #[test] + fn test_display_format_joined() { + let counts = ConfigCounts { + claude_md_count: 2, + rules_count: 3, + mcp_count: 5, + hooks_count: 2, + }; + + let parts = build_display_parts(&counts); + let display = parts.join(" | "); + + assert_eq!(display, "2 CLAUDE.md | 3 rules | 5 MCPs | 2 hooks"); + } + + #[test] + fn test_display_format_single_item() { + let counts = ConfigCounts { + claude_md_count: 0, + rules_count: 0, + mcp_count: 1, + hooks_count: 0, + }; + + let parts = build_display_parts(&counts); + let display = parts.join(" | "); + + assert_eq!(display, "1 MCP"); + } +} diff --git a/src/core/segments/mod.rs b/src/core/segments/mod.rs index ff036a9..8f036e3 100644 --- a/src/core/segments/mod.rs +++ b/src/core/segments/mod.rs @@ -1,3 +1,4 @@ +pub mod config_counts; pub mod context_window; pub mod cost; pub mod directory; @@ -25,6 +26,7 @@ pub struct SegmentData { } // Re-export all segment types +pub use config_counts::ConfigCountsSegment; pub use context_window::ContextWindowSegment; pub use cost::CostSegment; pub use directory::DirectorySegment; diff --git a/src/core/statusline.rs b/src/core/statusline.rs index 5f84bf1..3e33edf 100644 --- a/src/core/statusline.rs +++ b/src/core/statusline.rs @@ -510,6 +510,18 @@ pub fn collect_all_segments( let segment = UpdateSegment::new(); segment.collect(input) } + crate::config::SegmentId::ConfigCounts => { + let cache_ttl = segment_config + .options + .get("cache_ttl_secs") + .and_then(|v| v.as_u64()); + let segment = if let Some(ttl) = cache_ttl { + ConfigCountsSegment::with_cache_ttl(ttl) + } else { + ConfigCountsSegment::new() + }; + segment.collect(input) + } }; if let Some(data) = segment_data { diff --git a/src/main.rs b/src/main.rs index 3a3a170..567c3d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use ccometixline::cli::Cli; use ccometixline::config::{Config, InputData}; -use ccometixline::core::{collect_all_segments, StatusLineGenerator}; +use ccometixline::core::{collect_all_segments, MultilineConfig, MultilineRenderer, StatusLineGenerator}; use std::io::{self, IsTerminal}; fn main() -> Result<(), Box> { @@ -131,14 +131,23 @@ fn main() -> Result<(), Box> { let stdin = io::stdin(); let input: InputData = serde_json::from_reader(stdin.lock())?; - // Collect segment data - let segments_data = collect_all_segments(&config, &input); - - // Render statusline - let generator = StatusLineGenerator::new(config); - let statusline = generator.generate(segments_data); - - println!("{}", statusline); + // Check if multiline mode is enabled + if cli.multiline { + // Use multiline renderer with activity tracking + let multiline_config = MultilineConfig::default(); + let renderer = MultilineRenderer::new(config, multiline_config); + renderer.render_and_print(&input); + } else { + // Use standard single-line output + // Collect segment data + let segments_data = collect_all_segments(&config, &input); + + // Render statusline + let generator = StatusLineGenerator::new(config); + let statusline = generator.generate(segments_data); + + println!("{}", statusline); + } Ok(()) } diff --git a/src/ui/app.rs b/src/ui/app.rs index 4487dc9..7599f97 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -506,6 +506,7 @@ impl App { SegmentId::Session => "Session", SegmentId::OutputStyle => "Output Style", SegmentId::Update => "Update", + SegmentId::ConfigCounts => "Config Counts", }; let is_enabled = segment.enabled; self.status_message = Some(format!( @@ -533,6 +534,7 @@ impl App { SegmentId::Session => "Session", SegmentId::OutputStyle => "Output Style", SegmentId::Update => "Update", + SegmentId::ConfigCounts => "Config Counts", }; let is_enabled = segment.enabled; self.status_message = Some(format!( diff --git a/src/ui/components/preview.rs b/src/ui/components/preview.rs index 4195878..39bdaa5 100644 --- a/src/ui/components/preview.rs +++ b/src/ui/components/preview.rs @@ -183,6 +183,18 @@ impl PreviewComponent { map }, }, + SegmentId::ConfigCounts => SegmentData { + primary: "2 CLAUDE.md | 3 rules | 5 MCPs | 2 hooks".to_string(), + secondary: "".to_string(), + metadata: { + let mut map = HashMap::new(); + map.insert("claude_md_count".to_string(), "2".to_string()); + map.insert("rules_count".to_string(), "3".to_string()); + map.insert("mcp_count".to_string(), "5".to_string()); + map.insert("hooks_count".to_string(), "2".to_string()); + map + }, + }, }; segments_data.push((segment_config.clone(), mock_data)); diff --git a/src/ui/components/segment_list.rs b/src/ui/components/segment_list.rs index 832834b..0b24fb2 100644 --- a/src/ui/components/segment_list.rs +++ b/src/ui/components/segment_list.rs @@ -57,6 +57,7 @@ impl SegmentListComponent { SegmentId::Session => "Session", SegmentId::OutputStyle => "Output Style", SegmentId::Update => "Update", + SegmentId::ConfigCounts => "Config Counts", }; if is_selected { diff --git a/src/ui/components/settings.rs b/src/ui/components/settings.rs index aa65acb..e703268 100644 --- a/src/ui/components/settings.rs +++ b/src/ui/components/settings.rs @@ -36,6 +36,7 @@ impl SettingsComponent { SegmentId::Session => "Session", SegmentId::OutputStyle => "Output Style", SegmentId::Update => "Update", + SegmentId::ConfigCounts => "Config Counts", }; let current_icon = match config.style.mode { StyleMode::Plain => &segment.icon.plain, diff --git a/src/ui/themes/presets.rs b/src/ui/themes/presets.rs index 0a51dab..0939978 100644 --- a/src/ui/themes/presets.rs +++ b/src/ui/themes/presets.rs @@ -133,6 +133,7 @@ impl ThemePresets { theme_cometix::directory_segment(), theme_cometix::git_segment(), theme_cometix::context_window_segment(), + theme_cometix::config_counts_segment(), theme_cometix::usage_segment(), theme_cometix::cost_segment(), theme_cometix::session_segment(), @@ -153,6 +154,7 @@ impl ThemePresets { theme_default::directory_segment(), theme_default::git_segment(), theme_default::context_window_segment(), + theme_default::config_counts_segment(), theme_default::usage_segment(), theme_default::cost_segment(), theme_default::session_segment(), @@ -173,6 +175,7 @@ impl ThemePresets { theme_minimal::directory_segment(), theme_minimal::git_segment(), theme_minimal::context_window_segment(), + theme_minimal::config_counts_segment(), theme_minimal::usage_segment(), theme_minimal::cost_segment(), theme_minimal::session_segment(), @@ -193,6 +196,7 @@ impl ThemePresets { theme_gruvbox::directory_segment(), theme_gruvbox::git_segment(), theme_gruvbox::context_window_segment(), + theme_gruvbox::config_counts_segment(), theme_gruvbox::usage_segment(), theme_gruvbox::cost_segment(), theme_gruvbox::session_segment(), @@ -213,6 +217,7 @@ impl ThemePresets { theme_nord::directory_segment(), theme_nord::git_segment(), theme_nord::context_window_segment(), + theme_nord::config_counts_segment(), theme_nord::usage_segment(), theme_nord::cost_segment(), theme_nord::session_segment(), @@ -233,6 +238,7 @@ impl ThemePresets { theme_powerline_dark::directory_segment(), theme_powerline_dark::git_segment(), theme_powerline_dark::context_window_segment(), + theme_powerline_dark::config_counts_segment(), theme_powerline_dark::usage_segment(), theme_powerline_dark::cost_segment(), theme_powerline_dark::session_segment(), @@ -253,6 +259,7 @@ impl ThemePresets { theme_powerline_light::directory_segment(), theme_powerline_light::git_segment(), theme_powerline_light::context_window_segment(), + theme_powerline_light::config_counts_segment(), theme_powerline_light::usage_segment(), theme_powerline_light::cost_segment(), theme_powerline_light::session_segment(), @@ -273,6 +280,7 @@ impl ThemePresets { theme_powerline_rose_pine::directory_segment(), theme_powerline_rose_pine::git_segment(), theme_powerline_rose_pine::context_window_segment(), + theme_powerline_rose_pine::config_counts_segment(), theme_powerline_rose_pine::usage_segment(), theme_powerline_rose_pine::cost_segment(), theme_powerline_rose_pine::session_segment(), @@ -293,6 +301,7 @@ impl ThemePresets { theme_powerline_tokyo_night::directory_segment(), theme_powerline_tokyo_night::git_segment(), theme_powerline_tokyo_night::context_window_segment(), + theme_powerline_tokyo_night::config_counts_segment(), theme_powerline_tokyo_night::usage_segment(), theme_powerline_tokyo_night::cost_segment(), theme_powerline_tokyo_night::session_segment(), diff --git a/src/ui/themes/theme_cometix.rs b/src/ui/themes/theme_cometix.rs index dfcd1e6..89df799 100644 --- a/src/ui/themes/theme_cometix.rs +++ b/src/ui/themes/theme_cometix.rs @@ -79,6 +79,24 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Color16 { c16: 5 }), // Magenta + text: Some(AnsiColor::Color16 { c16: 7 }), // White + background: None, + }, + styles: TextStyleConfig { text_bold: true }, + options: HashMap::new(), + } +} + pub fn cost_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Cost, diff --git a/src/ui/themes/theme_default.rs b/src/ui/themes/theme_default.rs index 20b21ca..581573e 100644 --- a/src/ui/themes/theme_default.rs +++ b/src/ui/themes/theme_default.rs @@ -79,6 +79,24 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Color16 { c16: 5 }), // Magenta + text: Some(AnsiColor::Color16 { c16: 7 }), // White + background: None, + }, + styles: TextStyleConfig::default(), + options: HashMap::new(), + } +} + pub fn usage_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Usage, diff --git a/src/ui/themes/theme_gruvbox.rs b/src/ui/themes/theme_gruvbox.rs index 6b071e1..8d18a98 100644 --- a/src/ui/themes/theme_gruvbox.rs +++ b/src/ui/themes/theme_gruvbox.rs @@ -79,6 +79,24 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Color256 { c256: 175 }), // Gruvbox purple + text: Some(AnsiColor::Color256 { c256: 223 }), // Gruvbox light + background: None, + }, + styles: TextStyleConfig { text_bold: true }, + options: HashMap::new(), + } +} + pub fn cost_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Cost, diff --git a/src/ui/themes/theme_minimal.rs b/src/ui/themes/theme_minimal.rs index 0c1cdd6..bfbd87b 100644 --- a/src/ui/themes/theme_minimal.rs +++ b/src/ui/themes/theme_minimal.rs @@ -79,6 +79,24 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Color16 { c16: 5 }), // Magenta + text: Some(AnsiColor::Color16 { c16: 7 }), // White + background: None, + }, + styles: TextStyleConfig::default(), + options: HashMap::new(), + } +} + pub fn cost_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Cost, diff --git a/src/ui/themes/theme_nord.rs b/src/ui/themes/theme_nord.rs index 29bba68..3a0d555 100644 --- a/src/ui/themes/theme_nord.rs +++ b/src/ui/themes/theme_nord.rs @@ -127,6 +127,36 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Rgb { + r: 46, + g: 52, + b: 64, + }), + text: Some(AnsiColor::Rgb { + r: 46, + g: 52, + b: 64, + }), + background: Some(AnsiColor::Rgb { + r: 208, + g: 135, + b: 112, + }), // Nord orange background + }, + styles: TextStyleConfig::default(), + options: HashMap::new(), + } +} + pub fn cost_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Cost, diff --git a/src/ui/themes/theme_powerline_dark.rs b/src/ui/themes/theme_powerline_dark.rs index 06e7e4b..2d960a4 100644 --- a/src/ui/themes/theme_powerline_dark.rs +++ b/src/ui/themes/theme_powerline_dark.rs @@ -127,6 +127,36 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Rgb { + r: 198, + g: 120, + b: 221, + }), // Bright magenta + text: Some(AnsiColor::Rgb { + r: 209, + g: 213, + b: 219, + }), + background: Some(AnsiColor::Rgb { + r: 60, + g: 55, + b: 72, + }), // Powerline purple-ish background + }, + styles: TextStyleConfig::default(), + options: HashMap::new(), + } +} + pub fn cost_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Cost, diff --git a/src/ui/themes/theme_powerline_light.rs b/src/ui/themes/theme_powerline_light.rs index 7b95a23..14f10e4 100644 --- a/src/ui/themes/theme_powerline_light.rs +++ b/src/ui/themes/theme_powerline_light.rs @@ -119,6 +119,36 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Rgb { + r: 255, + g: 255, + b: 255, + }), + text: Some(AnsiColor::Rgb { + r: 255, + g: 255, + b: 255, + }), + background: Some(AnsiColor::Rgb { + r: 156, + g: 39, + b: 176, + }), // Purple background + }, + styles: TextStyleConfig::default(), + options: HashMap::new(), + } +} + pub fn cost_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Cost, diff --git a/src/ui/themes/theme_powerline_rose_pine.rs b/src/ui/themes/theme_powerline_rose_pine.rs index abea860..f8a0201 100644 --- a/src/ui/themes/theme_powerline_rose_pine.rs +++ b/src/ui/themes/theme_powerline_rose_pine.rs @@ -127,6 +127,36 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Rgb { + r: 235, + g: 111, + b: 146, + }), // Rose Pine love (pink) + text: Some(AnsiColor::Rgb { + r: 224, + g: 222, + b: 244, + }), + background: Some(AnsiColor::Rgb { + r: 45, + g: 42, + b: 67, + }), // Rose Pine surface + }, + styles: TextStyleConfig::default(), + options: HashMap::new(), + } +} + pub fn cost_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Cost, diff --git a/src/ui/themes/theme_powerline_tokyo_night.rs b/src/ui/themes/theme_powerline_tokyo_night.rs index 4e0143b..f05da8d 100644 --- a/src/ui/themes/theme_powerline_tokyo_night.rs +++ b/src/ui/themes/theme_powerline_tokyo_night.rs @@ -127,6 +127,36 @@ pub fn context_window_segment() -> SegmentConfig { } } +pub fn config_counts_segment() -> SegmentConfig { + SegmentConfig { + id: SegmentId::ConfigCounts, + enabled: true, + icon: IconConfig { + plain: "CFG".to_string(), + nerd_font: "\u{f013}".to_string(), // gear icon + }, + colors: ColorConfig { + icon: Some(AnsiColor::Rgb { + r: 187, + g: 154, + b: 247, + }), // Tokyo Night purple + text: Some(AnsiColor::Rgb { + r: 192, + g: 202, + b: 245, + }), + background: Some(AnsiColor::Rgb { + r: 52, + g: 47, + b: 75, + }), // Tokyo Night dark purple background + }, + styles: TextStyleConfig::default(), + options: HashMap::new(), + } +} + pub fn cost_segment() -> SegmentConfig { SegmentConfig { id: SegmentId::Cost,