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:
Dhanji R. Prasanna
2026-01-21 21:27:16 +05:30
parent 0f7961d3c6
commit 9325a43ff3
9 changed files with 264 additions and 12 deletions

View File

@@ -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,

View File

@@ -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?;

View File

@@ -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

View File

@@ -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 {

View File

@@ -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");
}
}

View File

@@ -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(

View File

@@ -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;
}
}