diff --git a/crates/g3-cli/src/accumulative.rs b/crates/g3-cli/src/accumulative.rs index 5dee156..27d62af 100644 --- a/crates/g3-cli/src/accumulative.rs +++ b/crates/g3-cli/src/accumulative.rs @@ -15,6 +15,7 @@ use crate::cli_args::Cli; use crate::interactive::run_interactive; use crate::simple_output::SimpleOutput; use crate::ui_writer_impl::ConsoleUiWriter; +use g3_core::ui_writer::UiWriter; use crate::utils::load_config_with_cli_overrides; /// Run accumulative autonomous mode - accumulates requirements from user input @@ -153,6 +154,7 @@ pub async fn run_accumulative_mode( // Create agent for this autonomous run let ui_writer = ConsoleUiWriter::new(); + ui_writer.set_workspace_path(workspace_dir.clone()); let agent = Agent::new_autonomous_with_readme_and_quiet( config.clone(), ui_writer, @@ -284,6 +286,7 @@ async fn handle_command( // Create agent for interactive mode with requirements context let ui_writer = ConsoleUiWriter::new(); + ui_writer.set_workspace_path(workspace_dir.clone()); let agent = Agent::new_with_readme_and_quiet( config, ui_writer, diff --git a/crates/g3-cli/src/agent_mode.rs b/crates/g3-cli/src/agent_mode.rs index ca9fa36..53b81a5 100644 --- a/crates/g3-cli/src/agent_mode.rs +++ b/crates/g3-cli/src/agent_mode.rs @@ -175,6 +175,7 @@ pub async fn run_agent_mode( let ui_writer = ConsoleUiWriter::new(); // Set agent mode on UI writer for visual differentiation (light gray tool names) ui_writer.set_agent_mode(true); + ui_writer.set_workspace_path(workspace_dir.clone()); let mut agent = Agent::new_with_custom_prompt(config, ui_writer, system_prompt, combined_content.clone()).await?; diff --git a/crates/g3-cli/src/autonomous.rs b/crates/g3-cli/src/autonomous.rs index b735eae..0037cf6 100644 --- a/crates/g3-cli/src/autonomous.rs +++ b/crates/g3-cli/src/autonomous.rs @@ -14,6 +14,7 @@ use crate::coach_feedback; use crate::metrics::{format_elapsed_time, generate_turn_histogram, TurnMetrics}; use crate::simple_output::SimpleOutput; use crate::ui_writer_impl::ConsoleUiWriter; +use g3_core::ui_writer::UiWriter; /// Run autonomous mode with coach-player feedback loop (console output). pub async fn run_autonomous( @@ -498,6 +499,7 @@ async fn execute_coach_turn( crate::filter_json::reset_json_tool_state(); let ui_writer = ConsoleUiWriter::new(); + ui_writer.set_workspace_path(project.workspace().to_path_buf()); let mut coach_agent = match Agent::new_autonomous_with_readme_and_quiet(coach_config, ui_writer, None, quiet) .await diff --git a/crates/g3-cli/src/commands.rs b/crates/g3-cli/src/commands.rs index 6033627..bbc95e9 100644 --- a/crates/g3-cli/src/commands.rs +++ b/crates/g3-cli/src/commands.rs @@ -360,6 +360,14 @@ pub async fn handle_command( Some(project) => { // Set project content in agent's system message if agent.set_project_content(Some(project.content.clone())) { + // Set project path on UI writer for path shortening + let project_name = project.path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("project") + .to_string(); + agent.ui_writer().set_project_path(project.path.clone(), project_name); + // Print loaded status print!( "{}Project loaded:{} {}\n", @@ -388,6 +396,7 @@ pub async fn handle_command( "/unproject" => { if active_project.is_some() { agent.clear_project_content(); + agent.ui_writer().clear_project(); *active_project = None; output.print("✅ Project unloaded. Context reset to original system message."); } else { diff --git a/crates/g3-cli/src/display.rs b/crates/g3-cli/src/display.rs index 360e945..ea72348 100644 --- a/crates/g3-cli/src/display.rs +++ b/crates/g3-cli/src/display.rs @@ -17,6 +17,83 @@ pub fn format_workspace_path(workspace_path: &Path) -> String { .unwrap_or(path_str) } +/// Shorten a path string for display by: +/// 1. Replacing project directory prefix with `/` (if project is active) +/// 2. Replacing workspace directory prefix with `./` +/// 3. Replacing home directory prefix with `~` +/// +/// This is useful for tool output where paths should be concise. +/// The project check happens first (most specific), then workspace, then home. +pub fn shorten_path(path: &str, workspace_path: Option<&std::path::Path>, project: Option<(&std::path::Path, &str)>) -> String { + // First, try to make it relative to project (most specific) + if let Some((project_path, project_name)) = project { + let project_str = project_path.display().to_string(); + if let Some(relative) = path.strip_prefix(&project_str) { + // Handle both "/subpath" and "" (exact match) cases + if relative.is_empty() { + return format!("{}/", project_name); + } else if let Some(stripped) = relative.strip_prefix('/') { + return format!("{}/{}", project_name, stripped); + } + } + } + + // First, try to make it relative to workspace + if let Some(workspace) = workspace_path { + let workspace_str = workspace.display().to_string(); + if let Some(relative) = path.strip_prefix(&workspace_str) { + // Handle both "/subpath" and "" (exact match) cases + if relative.is_empty() { + return "./".to_string(); + } else if let Some(stripped) = relative.strip_prefix('/') { + return format!("./{}", stripped); + } + } + } + + // Fall back to replacing home directory with ~ + if let Some(home) = dirs::home_dir() { + let home_str = home.display().to_string(); + if let Some(relative) = path.strip_prefix(&home_str) { + return format!("~{}", relative); + } + } + + path.to_string() +} + +/// Shorten any paths found within a shell command string. +/// This replaces project paths with `/`, workspace paths with `./`, and home paths with `~`. +pub fn shorten_paths_in_command(command: &str, workspace_path: Option<&std::path::Path>, project: Option<(&std::path::Path, &str)>) -> String { + let mut result = command.to_string(); + + // First, replace project paths (most specific) + if let Some((project_path, project_name)) = project { + let project_str = project_path.display().to_string(); + // Replace project path followed by / with project_name/ + result = result.replace(&format!("{}/", project_str), &format!("{}/", project_name)); + // Replace exact project path + result = result.replace(&project_str, project_name); + } + + // Then, replace workspace paths + if let Some(workspace) = workspace_path { + let workspace_str = workspace.display().to_string(); + // Replace workspace path followed by / with ./ + result = result.replace(&format!("{}/", workspace_str), "./"); + // Replace exact workspace path at word boundary + result = result.replace(&workspace_str, "."); + } + + // Then replace home directory paths + if let Some(home) = dirs::home_dir() { + let home_str = home.display().to_string(); + result = result.replace(&home_str, "~"); + } + + result +} + /// Print the workspace path in a consistent format. pub fn print_workspace_path(workspace_path: &Path) { let display = format_workspace_path(workspace_path); @@ -195,4 +272,91 @@ mod tests { }; assert!(with_readme.has_any()); } + + #[test] + fn test_shorten_path_workspace_relative() { + let workspace = PathBuf::from("/Users/test/projects/myapp"); + let path = "/Users/test/projects/myapp/src/main.rs"; + let shortened = shorten_path(path, Some(&workspace), None); + assert_eq!(shortened, "./src/main.rs"); + } + + #[test] + fn test_shorten_path_workspace_exact() { + let workspace = PathBuf::from("/Users/test/projects/myapp"); + let path = "/Users/test/projects/myapp"; + let shortened = shorten_path(path, Some(&workspace), None); + assert_eq!(shortened, "./"); + } + + #[test] + fn test_shorten_path_home_relative() { + // This test depends on having a home directory + if let Some(home) = dirs::home_dir() { + let path = format!("{}/other/project/file.rs", home.display()); + let shortened = shorten_path(&path, None, None); + assert_eq!(shortened, "~/other/project/file.rs"); + } + } + + #[test] + fn test_shorten_path_no_match() { + let workspace = PathBuf::from("/Users/test/projects/myapp"); + let path = "/tmp/other/file.rs"; + let shortened = shorten_path(path, Some(&workspace), None); + assert_eq!(shortened, "/tmp/other/file.rs"); + } + + #[test] + fn test_shorten_path_project_relative() { + let workspace = PathBuf::from("/Users/test/projects"); + let project_path = PathBuf::from("/Users/test/projects/appa_estate"); + let path = "/Users/test/projects/appa_estate/status.md"; + let shortened = shorten_path(path, Some(&workspace), Some((&project_path, "appa_estate"))); + assert_eq!(shortened, "appa_estate/status.md"); + } + + #[test] + fn test_shorten_path_project_takes_priority() { + // Project path is under workspace, but project shortening should take priority + let workspace = PathBuf::from("/Users/test/projects"); + let project_path = PathBuf::from("/Users/test/projects/appa_estate"); + let path = "/Users/test/projects/appa_estate/src/main.rs"; + let shortened = shorten_path(path, Some(&workspace), Some((&project_path, "appa_estate"))); + assert_eq!(shortened, "appa_estate/src/main.rs"); + } + + #[test] + fn test_shorten_paths_in_command_workspace() { + let workspace = PathBuf::from("/Users/test/projects/myapp"); + let command = "cat /Users/test/projects/myapp/src/main.rs"; + let shortened = shorten_paths_in_command(command, Some(&workspace), None); + assert_eq!(shortened, "cat ./src/main.rs"); + } + + #[test] + fn test_shorten_paths_in_command_home() { + if let Some(home) = dirs::home_dir() { + let command = format!("ls {}/Documents", home.display()); + let shortened = shorten_paths_in_command(&command, None, None); + assert_eq!(shortened, "ls ~/Documents"); + } + } + + #[test] + fn test_shorten_paths_in_command_multiple() { + let workspace = PathBuf::from("/Users/test/projects/myapp"); + let command = "diff /Users/test/projects/myapp/a.rs /Users/test/projects/myapp/b.rs"; + let shortened = shorten_paths_in_command(command, Some(&workspace), None); + assert_eq!(shortened, "diff ./a.rs ./b.rs"); + } + + #[test] + fn test_shorten_paths_in_command_project() { + let workspace = PathBuf::from("/Users/test/projects"); + let project_path = PathBuf::from("/Users/test/projects/appa_estate"); + let command = "cat /Users/test/projects/appa_estate/status.md"; + let shortened = shorten_paths_in_command(command, Some(&workspace), Some((&project_path, "appa_estate"))); + assert_eq!(shortened, "cat appa_estate/status.md"); + } } diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index 0e428b2..1f5f287 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -42,6 +42,7 @@ use interactive::run_interactive; use project_files::{combine_project_content, read_agents_config, read_include_prompt, read_workspace_memory, read_project_readme}; use simple_output::SimpleOutput; use ui_writer_impl::ConsoleUiWriter; +use g3_core::ui_writer::UiWriter; use utils::{initialize_logging, load_config_with_cli_overrides, setup_workspace_directory}; use template::process_template; @@ -167,6 +168,7 @@ async fn run_console_mode( } let ui_writer = ConsoleUiWriter::new(); + ui_writer.set_workspace_path(workspace_dir.clone()); let mut agent = if cli.autonomous { Agent::new_autonomous_with_readme_and_quiet( diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index 1315a5c..c375add 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -1,4 +1,5 @@ use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state, ToolParsingHint}; +use crate::display::{shorten_path, shorten_paths_in_command}; use crate::streaming_markdown::StreamingMarkdownFormatter; use g3_core::ui_writer::UiWriter; use std::io::{self, Write}; @@ -137,6 +138,12 @@ impl ParsingHintState { pub struct ConsoleUiWriter { current_tool_name: std::sync::Mutex>, current_tool_args: std::sync::Mutex>, + /// Workspace path for shortening displayed paths + workspace_path: std::sync::Mutex>, + /// Project path for shortening displayed paths (takes priority over workspace) + project_path: std::sync::Mutex>, + /// Project name for display (e.g., "appa_estate") + project_name: std::sync::Mutex>, current_output_line: std::sync::Mutex>, output_line_printed: std::sync::Mutex, /// Track if we're in shell compact mode (for appending timing to output line) @@ -191,6 +198,9 @@ impl ConsoleUiWriter { Self { current_tool_name: std::sync::Mutex::new(None), current_tool_args: std::sync::Mutex::new(Vec::new()), + workspace_path: std::sync::Mutex::new(None), + project_path: std::sync::Mutex::new(None), + project_name: std::sync::Mutex::new(None), current_output_line: std::sync::Mutex::new(None), output_line_printed: std::sync::Mutex::new(false), is_shell_compact: std::sync::Mutex::new(false), @@ -201,6 +211,18 @@ impl ConsoleUiWriter { } } +impl ConsoleUiWriter { + fn get_workspace_path(&self) -> Option { + self.workspace_path.lock().unwrap().clone() + } + + fn get_project_info(&self) -> Option<(std::path::PathBuf, String)> { + let path = self.project_path.lock().unwrap().clone()?; + let name = self.project_name.lock().unwrap().clone()?; + Some((path, name)) + } +} + impl UiWriter for ConsoleUiWriter { fn print(&self, message: &str) { print!("{}", message); @@ -308,17 +330,28 @@ impl UiWriter for ConsoleUiWriter { // For multi-line values, only show the first line let first_line = value.lines().next().unwrap_or(""); - // Truncate long values for display - let display_value = if first_line.len() > 80 { + // Get workspace path for shortening + let workspace = self.get_workspace_path(); + let workspace_ref = workspace.as_deref(); + + // Get project info for shortening + let project_info = self.get_project_info(); + let project_ref = project_info.as_ref().map(|(p, n)| (p.as_path(), n.as_str())); + + // Shorten paths in the value (handles both file paths and shell commands) + let shortened = shorten_paths_in_command(first_line, workspace_ref, project_ref); + + // Truncate long values for display (after shortening) + let display_value = if shortened.chars().count() > 80 { // Use char_indices to safely truncate at character boundary - let truncate_at = first_line + let truncate_at = shortened .char_indices() .nth(77) .map(|(i, _)| i) - .unwrap_or(first_line.len()); - format!("{}...", &first_line[..truncate_at]) + .unwrap_or(shortened.len()); + format!("{}...", &shortened[..truncate_at]) } else { - first_line.to_string() + shortened }; // Add range information for read_file tool calls @@ -500,16 +533,21 @@ impl UiWriter for ConsoleUiWriter { String::new() } } else { - // Truncate long paths - if file_path.len() > 60 { - let truncate_at = file_path + // Shorten path (project -> name/, workspace -> ./, home -> ~) then truncate if still long + let workspace = self.get_workspace_path(); + let project_info = self.get_project_info(); + let project_ref = project_info.as_ref().map(|(p, n)| (p.as_path(), n.as_str())); + let shortened = shorten_path(file_path, workspace.as_deref(), project_ref); + + if shortened.chars().count() > 60 { + let truncate_at = shortened .char_indices() .nth(57) .map(|(i, _)| i) - .unwrap_or(file_path.len()); - format!("{}", &file_path[..truncate_at]) + .unwrap_or(shortened.len()); + format!("{}...", &shortened[..truncate_at]) } else { - file_path.to_string() + shortened } }; @@ -806,4 +844,18 @@ impl UiWriter for ConsoleUiWriter { fn set_agent_mode(&self, is_agent_mode: bool) { self.hint_state.is_agent_mode.store(is_agent_mode, Ordering::Relaxed); } + + fn set_workspace_path(&self, path: std::path::PathBuf) { + *self.workspace_path.lock().unwrap() = Some(path); + } + + fn set_project_path(&self, path: std::path::PathBuf, name: String) { + *self.project_path.lock().unwrap() = Some(path); + *self.project_name.lock().unwrap() = Some(name); + } + + fn clear_project(&self) { + *self.project_path.lock().unwrap() = None; + *self.project_name.lock().unwrap() = None; + } } diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index c9fa4ef..78d7e54 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -1055,6 +1055,11 @@ impl Agent { &self.context_window } + /// Get a reference to the UI writer. + pub fn ui_writer(&self) -> &W { + &self.ui_writer + } + /// Add a message directly to the context window. /// Used for injecting discovery messages before the first LLM turn. pub fn add_message_to_context(&mut self, message: Message) { diff --git a/crates/g3-core/src/ui_writer.rs b/crates/g3-core/src/ui_writer.rs index f186c4e..5a71534 100644 --- a/crates/g3-core/src/ui_writer.rs +++ b/crates/g3-core/src/ui_writer.rs @@ -126,6 +126,20 @@ pub trait UiWriter: Send + Sync { /// When in agent mode, tool names may be displayed differently (e.g., different color). /// Default implementation does nothing. fn set_agent_mode(&self, _is_agent_mode: bool) {} + + /// Set the workspace path for shortening displayed paths. + /// Paths under this directory will be shown as `./relative/path`. + /// Default implementation does nothing. + fn set_workspace_path(&self, _path: std::path::PathBuf) {} + + /// Set the active project path and name for shortening displayed paths. + /// Paths under this directory will be shown as `/relative/path`. + /// Default implementation does nothing. + fn set_project_path(&self, _path: std::path::PathBuf, _name: String) {} + + /// Clear the active project (when project is unloaded). + /// Default implementation does nothing. + fn clear_project(&self) {} } /// A no-op implementation for when UI output is not needed