feat(cli): shorten file paths in tool output display
Add three-level path shortening hierarchy for cleaner CLI output:
1. Project path -> <project_name>/... (when project loaded via /project)
2. Workspace path -> ./... (relative to current working directory)
3. Home path -> ~/... (fallback for paths under home directory)
Changes:
- Add shorten_path() and shorten_paths_in_command() functions in display.rs
- Add project_path/project_name fields to ConsoleUiWriter
- Add set_workspace_path(), set_project_path(), clear_project() to UiWriter trait
- Add ui_writer() getter to Agent struct
- Wire up project path setting in /project and /unproject commands
- Set workspace path when creating agents in all CLI modes
Before: ● read_file | /Users/dhanji/icloud/butler/projects/appa_estate/status.md
After: ● read_file | appa_estate/status.md (with project loaded)
● read_file | ./src/main.rs (workspace-relative)
● read_file | ~/Documents/file.txt (home-relative)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -360,6 +360,14 @@ pub async fn handle_command<W: UiWriter>(
|
||||
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<W: UiWriter>(
|
||||
"/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 {
|
||||
|
||||
@@ -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 `<project_name>/` (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 `<project_name>/`, 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Option<String>>,
|
||||
current_tool_args: std::sync::Mutex<Vec<(String, String)>>,
|
||||
/// Workspace path for shortening displayed paths
|
||||
workspace_path: std::sync::Mutex<Option<std::path::PathBuf>>,
|
||||
/// Project path for shortening displayed paths (takes priority over workspace)
|
||||
project_path: std::sync::Mutex<Option<std::path::PathBuf>>,
|
||||
/// Project name for display (e.g., "appa_estate")
|
||||
project_name: std::sync::Mutex<Option<String>>,
|
||||
current_output_line: std::sync::Mutex<Option<String>>,
|
||||
output_line_printed: std::sync::Mutex<bool>,
|
||||
/// 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<std::path::PathBuf> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,6 +1055,11 @@ impl<W: UiWriter> Agent<W> {
|
||||
&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) {
|
||||
|
||||
@@ -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 `<project_name>/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
|
||||
|
||||
Reference in New Issue
Block a user