diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index de6544fa..9238975a 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -979,7 +979,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> { "DeepSeek Setup".truecolor(aqua_r, aqua_g, aqua_b).bold() ); println!("{}", "==============".truecolor(sky_r, sky_g, sky_b)); - println!("Workspace: {}", workspace.display()); + println!("Workspace: {}", crate::utils::display_path(workspace)); if run_mcp { let mcp_path = config.mcp_config_path(); @@ -1022,10 +1022,13 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> { if args.local { println!( " Local skills dir enabled for this workspace: {}", - skills_dir.display() + crate::utils::display_path(&skills_dir) ); } else { - println!(" Skills dir: {}", skills_dir.display()); + println!( + " Skills dir: {}", + crate::utils::display_path(&skills_dir) + ); } println!(" Next: run the TUI and use `/skills` then `/skill getting-started`."); } @@ -1035,7 +1038,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> { let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?; report_write_status("Tools README", &dir.join("README.md"), readme_status); report_write_status("Example tool", &dir.join("example.sh"), example_status); - println!(" Tools dir: {}", dir.display()); + println!(" Tools dir: {}", crate::utils::display_path(&dir)); println!(" Next: drop scripts here; surface them via skills/MCP when ready."); } @@ -1045,7 +1048,10 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> { init_plugins_dir(&plugins_dir, args.force)?; report_write_status("Plugins README", &readme_path, readme_status); report_write_status("Example plugin", &example_path, example_status); - println!(" Plugins dir: {}", plugins_dir.display()); + println!( + " Plugins dir: {}", + crate::utils::display_path(&plugins_dir) + ); println!(" Next: copy the example dir, edit PLUGIN.md, wire via skill/MCP."); } @@ -1200,7 +1206,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { println!( " · skills: {} at {}", skills_count_for(&skills_dir), - skills_dir.display() + crate::utils::display_path(&skills_dir) ); let tools_dir = default_tools_dir(); @@ -1216,7 +1222,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { } else { 0 }, - tools_dir.display() + crate::utils::display_path(&tools_dir) ); let plugins_dir = default_plugins_dir(); @@ -1232,7 +1238,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { } else { 0 }, - plugins_dir.display() + crate::utils::display_path(&plugins_dir) ); let sandbox = crate::sandbox::get_platform_sandbox(); @@ -1348,16 +1354,16 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( " {} config.toml found at {}", "✓".truecolor(aqua_r, aqua_g, aqua_b), - config_path.display() + crate::utils::display_path(&config_path) ); } else { println!( " {} config.toml not found at {} (using defaults/env)", "!".truecolor(sky_r, sky_g, sky_b), - config_path.display() + crate::utils::display_path(&config_path) ); } - println!(" workspace: {}", workspace.display()); + println!(" workspace: {}", crate::utils::display_path(workspace)); // Check API keys println!(); @@ -1477,7 +1483,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( " {} MCP config found at {}", "✓".truecolor(aqua_r, aqua_g, aqua_b), - mcp_config_path.display() + crate::utils::display_path(&mcp_config_path) ); match load_mcp_config(&mcp_config_path) { Ok(cfg) if cfg.servers.is_empty() => { @@ -1532,7 +1538,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( " {} MCP config not found at {}", "·".dimmed(), - mcp_config_path.display() + crate::utils::display_path(&mcp_config_path) ); println!(" Run `deepseek mcp init` or `deepseek setup --mcp`."); } @@ -1561,14 +1567,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( " {} local skills dir found at {} ({} items)", "✓".truecolor(aqua_r, aqua_g, aqua_b), - local_skills_dir.display(), + crate::utils::display_path(&local_skills_dir), describe_dir(&local_skills_dir) ); } else { println!( " {} local skills dir not found at {}", "·".dimmed(), - local_skills_dir.display() + crate::utils::display_path(&local_skills_dir) ); } @@ -1576,14 +1582,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( " {} .agents skills dir found at {} ({} items)", "✓".truecolor(aqua_r, aqua_g, aqua_b), - agents_skills_dir.display(), + crate::utils::display_path(&agents_skills_dir), describe_dir(&agents_skills_dir) ); } else { println!( " {} .agents skills dir not found at {}", "·".dimmed(), - agents_skills_dir.display() + crate::utils::display_path(&agents_skills_dir) ); } @@ -1591,21 +1597,21 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( " {} global skills dir found at {} ({} items)", "✓".truecolor(aqua_r, aqua_g, aqua_b), - global_skills_dir.display(), + crate::utils::display_path(&global_skills_dir), describe_dir(&global_skills_dir) ); } else { println!( " {} global skills dir not found at {}", "·".dimmed(), - global_skills_dir.display() + crate::utils::display_path(&global_skills_dir) ); } println!( " {} selected skills dir: {}", "·".dimmed(), - selected_skills_dir.display() + crate::utils::display_path(&selected_skills_dir) ); if !agents_skills_dir.exists() && !local_skills_dir.exists() && !global_skills_dir.exists() { println!(" Run `deepseek setup --skills` (or add --local for ./skills)."); @@ -1620,14 +1626,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( " {} tools dir found at {} ({} items)", "✓".truecolor(aqua_r, aqua_g, aqua_b), - tools_dir.display(), + crate::utils::display_path(&tools_dir), count ); } else { println!( " {} tools dir not found at {}", "·".dimmed(), - tools_dir.display() + crate::utils::display_path(&tools_dir) ); println!(" Run `deepseek-tui setup --tools` to scaffold a starter dir."); } @@ -1641,14 +1647,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt println!( " {} plugins dir found at {} ({} items)", "✓".truecolor(aqua_r, aqua_g, aqua_b), - plugins_dir.display(), + crate::utils::display_path(&plugins_dir), count ); } else { println!( " {} plugins dir not found at {}", "·".dimmed(), - plugins_dir.display() + crate::utils::display_path(&plugins_dir) ); println!(" Run `deepseek-tui setup --plugins` to scaffold a starter dir."); } diff --git a/crates/tui/src/tui/context_inspector.rs b/crates/tui/src/tui/context_inspector.rs index d0e7a586..c9e5c091 100644 --- a/crates/tui/src/tui/context_inspector.rs +++ b/crates/tui/src/tui/context_inspector.rs @@ -30,7 +30,11 @@ pub fn build_context_inspector_text(app: &App) -> String { let _ = writeln!(out, "Session Context"); let _ = writeln!(out, "---------------"); let _ = writeln!(out, "Model: {}", app.model); - let _ = writeln!(out, "Workspace: {}", app.workspace.display()); + let _ = writeln!( + out, + "Workspace: {}", + crate::utils::display_path(&app.workspace) + ); if let Some(session_id) = app.current_session_id.as_deref() { let _ = writeln!(out, "Session: {}", session_id); } diff --git a/crates/tui/src/tui/onboarding/trust_directory.rs b/crates/tui/src/tui/onboarding/trust_directory.rs index 3d51107a..44c18b7d 100644 --- a/crates/tui/src/tui/onboarding/trust_directory.rs +++ b/crates/tui/src/tui/onboarding/trust_directory.rs @@ -20,7 +20,7 @@ pub fn lines(app: &App) -> Vec> { Style::default().fg(palette::TEXT_PRIMARY), ))); lines.push(Line::from(Span::styled( - format!("Workspace: {}", app.workspace.display()), + format!("Workspace: {}", crate::utils::display_path(&app.workspace)), Style::default().fg(palette::TEXT_MUTED), ))); lines.push(Line::from("")); diff --git a/crates/tui/src/tui/shell_job_routing.rs b/crates/tui/src/tui/shell_job_routing.rs index 428c4351..6c08566f 100644 --- a/crates/tui/src/tui/shell_job_routing.rs +++ b/crates/tui/src/tui/shell_job_routing.rs @@ -52,7 +52,7 @@ pub(super) fn format_shell_job_list(jobs: &[ShellJobSnapshot]) -> String { job.exit_code, task )); - lines.push(format!(" cwd: {}", job.cwd.display())); + lines.push(format!(" cwd: {}", crate::utils::display_path(&job.cwd))); lines.push(format!(" cmd: {}", job.command)); let tail = if !job.stderr_tail.trim().is_empty() { job.stderr_tail.trim() @@ -115,7 +115,7 @@ fn format_shell_job_detail(detail: &ShellJobDetail) -> String { format!("Job: {}", job.id), format!("Status: {}", status_label(&job.status, job.stale)), format!("Command: {}", job.command), - format!("Cwd: {}", job.cwd.display()), + format!("Cwd: {}", crate::utils::display_path(&job.cwd)), format!("Elapsed: {}", format_elapsed(job.elapsed_ms)), format!("Exit Code: {:?}", job.exit_code), format!("Stdin Available: {}", job.stdin_available), diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index fabc0c7b..c5fb44ec 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -489,7 +489,10 @@ fn format_task_detail(task: &TaskRecord) -> String { lines.push(format!("Status: {}", task_status_label(task.status))); lines.push(format!("Mode: {}", task.mode)); lines.push(format!("Model: {}", task.model)); - lines.push(format!("Workspace: {}", task.workspace.display())); + lines.push(format!( + "Workspace: {}", + crate::utils::display_path(&task.workspace) + )); if let Some(thread_id) = task.thread_id.as_ref() { lines.push(format!("Runtime Thread: {thread_id}")); } diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index 1801ca6b..80a8e403 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -186,6 +186,33 @@ pub fn url_encode(input: &str) -> String { encoded } +/// Render a path for **user-facing display** with the home directory +/// contracted to `~`. Use this in the TUI, doctor/setup stdout, and any +/// other place a viewer might see the output (screenshot, video, +/// pasted-into-issue help). On macOS/Linux the absolute path +/// `/Users//...` or `/home//...` reveals the OS account name, +/// which is often the same as a public handle — undesirable for users +/// who share their terminal. +/// +/// **Do not use** this for paths that get persisted (sessions, audit log) +/// or sent to the LLM provider — those want full fidelity so they +/// resolve correctly across processes. +#[must_use] +pub fn display_path(path: &Path) -> String { + let Some(home) = dirs::home_dir() else { + return path.display().to_string(); + }; + if let Ok(rest) = path.strip_prefix(&home) { + if rest.as_os_str().is_empty() { + return "~".to_string(); + } + // Render with the platform-correct separator after the tilde. + let sep = std::path::MAIN_SEPARATOR; + return format!("~{sep}{}", rest.display()); + } + path.display().to_string() +} + /// Estimate the total character count across message content blocks. #[must_use] pub fn estimate_message_chars(messages: &[Message]) -> usize { @@ -205,3 +232,74 @@ pub fn estimate_message_chars(messages: &[Message]) -> usize { } total } + +#[cfg(test)] +mod tests { + use super::display_path; + use std::path::PathBuf; + + /// Save and restore $HOME inside one test so a panic anywhere can't + /// poison sibling tests that read the env var. + fn with_home(home: &str, f: impl FnOnce() -> R) -> R { + let prev = std::env::var_os("HOME"); + // SAFETY: tests in this crate are run single-threaded with respect + // to env-var mutation by the integration harness, and we restore + // immediately after the closure. + unsafe { std::env::set_var("HOME", home) }; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + match prev { + Some(v) => unsafe { std::env::set_var("HOME", v) }, + None => unsafe { std::env::remove_var("HOME") }, + } + match result { + Ok(v) => v, + Err(p) => std::panic::resume_unwind(p), + } + } + + #[test] + fn display_path_contracts_home_prefix() { + with_home("/Users/alice", || { + assert_eq!( + display_path(&PathBuf::from("/Users/alice/projects/foo")), + format!( + "~{}projects{}foo", + std::path::MAIN_SEPARATOR, + std::path::MAIN_SEPARATOR + ), + ); + }); + } + + #[test] + fn display_path_returns_bare_tilde_for_home_itself() { + with_home("/Users/alice", || { + assert_eq!(display_path(&PathBuf::from("/Users/alice")), "~"); + }); + } + + #[test] + fn display_path_leaves_unrelated_paths_alone() { + with_home("/Users/alice", || { + // Different user — must not get rewritten or share the tilde. + assert_eq!( + display_path(&PathBuf::from("/Users/bob/Code")), + "/Users/bob/Code".to_string() + ); + // System path must stay absolute. + assert_eq!(display_path(&PathBuf::from("/etc/hosts")), "/etc/hosts"); + }); + } + + #[test] + fn display_path_does_not_match_username_prefix() { + // Regression guard: a directory named like the user's home + // *prefix* but not under it must not get rewritten. + with_home("/Users/alice", || { + assert_eq!( + display_path(&PathBuf::from("/Users/alice2/work")), + "/Users/alice2/work" + ); + }); + } +}