diff --git a/crates/g3-cli/src/agent_mode.rs b/crates/g3-cli/src/agent_mode.rs index f14164d..925240e 100644 --- a/crates/g3-cli/src/agent_mode.rs +++ b/crates/g3-cli/src/agent_mode.rs @@ -8,6 +8,7 @@ use g3_core::ui_writer::UiWriter; use g3_core::Agent; use crate::project_files::{combine_project_content, read_agents_config, read_include_prompt, read_project_memory, read_project_readme}; +use crate::display::{LoadedContent, print_loaded_status, print_workspace_path}; use crate::language_prompts::{get_language_prompts_for_workspace, get_agent_language_prompts_for_workspace_with_langs}; use crate::simple_output::SimpleOutput; use crate::embedded_agents::load_agent_prompt; @@ -103,18 +104,8 @@ pub async fn run_agent_mode( if !chat { output.print(&format!(">> agent mode | {} ({})", agent_name, source)); } - let workspace_display = { - let path_str = workspace_dir.display().to_string(); - dirs::home_dir() - .and_then(|home| { - path_str - .strip_prefix(&home.display().to_string()) - .map(|s| format!("~{}", s)) - }) - .unwrap_or(path_str) - }; // Always print workspace path (it's part of minimal output) - print!("{}-> {}{}\n", crossterm::style::SetForegroundColor(crossterm::style::Color::DarkGrey), workspace_display, crossterm::style::ResetColor); + print_workspace_path(&workspace_dir); // Load config let mut config = g3_config::Config::load(config_path)?; @@ -143,33 +134,18 @@ pub async fn run_agent_mode( // Read include prompt early so we can show it in the status line let include_prompt = read_include_prompt(include_prompt_path.as_deref()); - // Build status line showing only what was loaded (in load order) - let mut loaded_items: Vec = Vec::new(); - if readme_content_opt.is_some() { - loaded_items.push("README".to_string()); - } - if agents_content_opt.is_some() { - loaded_items.push("AGENTS.md".to_string()); - } - if let Some(path) = &include_prompt_path { - if include_prompt.is_some() { - let filename = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "prompt".to_string()); - loaded_items.push(filename); - } - } - if memory_content_opt.is_some() { - loaded_items.push("Memory".to_string()); - } - // Print status line only if something was loaded - if !loaded_items.is_empty() { - let status_str = loaded_items.iter().map(|s| format!("✓ {}", s)).collect::>().join(" "); - print!( - "{} {}{}\n", - crossterm::style::SetForegroundColor(crossterm::style::Color::DarkGrey), - status_str, - crossterm::style::ResetColor - ); - } + // Build and print status line showing what was loaded + let include_filename = include_prompt_path.as_ref() + .filter(|_| include_prompt.is_some()) + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()); + let loaded = LoadedContent::new( + readme_content_opt.is_some(), + agents_content_opt.is_some(), + memory_content_opt.is_some(), + include_filename, + ); + print_loaded_status(&loaded); // Get language-specific prompts (same mechanism as normal mode) let language_content = get_language_prompts_for_workspace(&workspace_dir); diff --git a/crates/g3-cli/src/display.rs b/crates/g3-cli/src/display.rs new file mode 100644 index 0000000..4a139a5 --- /dev/null +++ b/crates/g3-cli/src/display.rs @@ -0,0 +1,197 @@ +//! Display utilities for G3 CLI. +//! +//! Provides shared display functions used by both interactive mode and agent mode. + +use crossterm::style::{Color, ResetColor, SetForegroundColor}; +use std::path::Path; + +/// Format a workspace path for display, replacing home directory with ~. +pub fn format_workspace_path(workspace_path: &Path) -> String { + let path_str = workspace_path.display().to_string(); + dirs::home_dir() + .and_then(|home| { + path_str + .strip_prefix(&home.display().to_string()) + .map(|s| format!("~{}", s)) + }) + .unwrap_or(path_str) +} + +/// Print the workspace path in a consistent format. +pub fn print_workspace_path(workspace_path: &Path) { + let display = format_workspace_path(workspace_path); + print!( + "{}-> {}{}", + SetForegroundColor(Color::DarkGrey), + display, + ResetColor + ); + println!(); +} + +/// Information about what project files were loaded. +#[derive(Default)] +pub struct LoadedContent { + pub has_readme: bool, + pub has_agents: bool, + pub has_memory: bool, + pub include_prompt_filename: Option, +} + +impl LoadedContent { + /// Create from explicit boolean flags. + pub fn new(has_readme: bool, has_agents: bool, has_memory: bool, include_prompt_filename: Option) -> Self { + Self { + has_readme, + has_agents, + has_memory, + include_prompt_filename, + } + } + + /// Create from combined content string by detecting markers. + pub fn from_combined_content(content: &str) -> Self { + Self { + has_readme: content.contains("Project README"), + has_agents: content.contains("Agent Configuration"), + has_memory: content.contains("=== Project Memory"), + include_prompt_filename: if content.contains("Included Prompt") { + Some("prompt".to_string()) // Default name when we can't determine the actual filename + } else { + None + }, + } + } + + /// Create with explicit include prompt filename. + pub fn with_include_prompt_filename(mut self, filename: Option) -> Self { + if self.include_prompt_filename.is_some() { + self.include_prompt_filename = filename; + } + self + } + + /// Check if any content was loaded. + pub fn has_any(&self) -> bool { + self.has_readme || self.has_agents || self.has_memory || self.include_prompt_filename.is_some() + } + + /// Build a list of loaded item names in load order. + pub fn to_loaded_items(&self) -> Vec { + let mut items = Vec::new(); + if self.has_readme { + items.push("README".to_string()); + } + if self.has_agents { + items.push("AGENTS.md".to_string()); + } + if let Some(ref filename) = self.include_prompt_filename { + items.push(filename.clone()); + } + if self.has_memory { + items.push("Memory".to_string()); + } + items + } +} + +/// Print a status line showing what project files were loaded. +/// Format: " ✓ README ✓ AGENTS.md ✓ Memory" +pub fn print_loaded_status(loaded: &LoadedContent) { + if !loaded.has_any() { + return; + } + + let items = loaded.to_loaded_items(); + let status_str = items + .iter() + .map(|s| format!("✓ {}", s)) + .collect::>() + .join(" "); + + print!( + "{} {}{}", + SetForegroundColor(Color::DarkGrey), + status_str, + ResetColor + ); + println!(); +} + +/// Print the project name/heading from README content. +pub fn print_project_heading(heading: &str) { + print!( + "{}>> {}{}", + SetForegroundColor(Color::DarkGrey), + heading, + ResetColor + ); + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_format_workspace_path_with_home() { + // This test depends on having a home directory + if let Some(home) = dirs::home_dir() { + let test_path = home.join("projects").join("myapp"); + let formatted = format_workspace_path(&test_path); + assert!(formatted.starts_with("~/"), "Expected ~/ prefix, got: {}", formatted); + assert!(formatted.contains("projects/myapp")); + } + } + + #[test] + fn test_format_workspace_path_without_home() { + let test_path = PathBuf::from("/tmp/workspace"); + let formatted = format_workspace_path(&test_path); + assert_eq!(formatted, "/tmp/workspace"); + } + + #[test] + fn test_loaded_content_from_combined() { + let content = "Project README\nAgent Configuration\n=== Project Memory"; + let loaded = LoadedContent::from_combined_content(content); + assert!(loaded.has_readme); + assert!(loaded.has_agents); + assert!(loaded.has_memory); + assert!(loaded.include_prompt_filename.is_none()); + } + + #[test] + fn test_loaded_content_with_include_prompt() { + let content = "Project README\nIncluded Prompt"; + let loaded = LoadedContent::from_combined_content(content) + .with_include_prompt_filename(Some("custom.md".to_string())); + assert!(loaded.has_readme); + assert_eq!(loaded.include_prompt_filename, Some("custom.md".to_string())); + } + + #[test] + fn test_loaded_content_to_items_order() { + let loaded = LoadedContent { + has_readme: true, + has_agents: true, + has_memory: true, + include_prompt_filename: Some("prompt.md".to_string()), + }; + let items = loaded.to_loaded_items(); + assert_eq!(items, vec!["README", "AGENTS.md", "prompt.md", "Memory"]); + } + + #[test] + fn test_loaded_content_has_any() { + let empty = LoadedContent::default(); + assert!(!empty.has_any()); + + let with_readme = LoadedContent { + has_readme: true, + ..Default::default() + }; + assert!(with_readme.has_any()); + } +} diff --git a/crates/g3-cli/src/interactive.rs b/crates/g3-cli/src/interactive.rs index 3e90710..6c2bfb3 100644 --- a/crates/g3-cli/src/interactive.rs +++ b/crates/g3-cli/src/interactive.rs @@ -11,6 +11,7 @@ use tracing::{debug, error}; use g3_core::ui_writer::UiWriter; use g3_core::Agent; +use crate::display::{LoadedContent, print_loaded_status, print_project_heading, print_workspace_path}; use crate::g3_status::{G3Status, Status}; use crate::project_files::extract_readme_heading; use crate::simple_output::SimpleOutput; @@ -100,69 +101,22 @@ pub async fn run_interactive( } } - // Display message if AGENTS.md or README was loaded + // Display message if AGENTS.md or README was loaded if let Some(ref content) = combined_content { - // Check what was loaded - let has_agents = content.contains("Agent Configuration"); - let has_readme = content.contains("Project README"); - let has_include_prompt = content.contains("Included Prompt"); - let has_memory = content.contains("=== Project Memory"); + let loaded = LoadedContent::from_combined_content(content); // Extract project name if README is loaded - let project_name = if has_readme { - // Extract the first heading or title from the README - extract_readme_heading(content) - } else { - None - }; - - if let Some(name) = project_name { - print!("{}>> {}{}\n", SetForegroundColor(Color::DarkGrey), name, ResetColor); + if loaded.has_readme { + if let Some(name) = extract_readme_heading(content) { + print_project_heading(&name); + } } - // Build status line showing only what was loaded (in load order) - let mut loaded_items: Vec<&str> = Vec::new(); - if has_readme { - loaded_items.push("README"); - } - if has_agents { - loaded_items.push("AGENTS.md"); - } - if has_include_prompt { - loaded_items.push("prompt"); - } - if has_memory { - loaded_items.push("Memory"); - } - // Print status line only if something was loaded - if !loaded_items.is_empty() { - let status_str = loaded_items.iter().map(|s| format!("✓ {}", s)).collect::>().join(" "); - print!( - "{} {}{}\n", - SetForegroundColor(Color::DarkGrey), - status_str, - ResetColor - ); - } + print_loaded_status(&loaded); } // Display workspace path - let workspace_display = { - let path_str = workspace_path.display().to_string(); - dirs::home_dir() - .and_then(|home| { - path_str - .strip_prefix(&home.display().to_string()) - .map(|s| format!("~{}", s)) - }) - .unwrap_or(path_str) - }; - print!( - "{}-> {}{}\n", - SetForegroundColor(Color::DarkGrey), - workspace_display, - ResetColor - ); + print_workspace_path(workspace_path); output.print(""); } diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 4adf8e0..00803c7 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -12,6 +12,7 @@ mod agent_mode; mod autonomous; mod cli_args; mod coach_feedback; +mod display; mod interactive; mod simple_output; mod task_execution;