diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 67d118c7..9446b7c3 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -719,7 +719,13 @@ pub fn get_command_info(name: &str) -> Option<&'static CommandInfo> { /// Get all command names matching a prefix, including both built-in /// static commands and user-defined commands, formatted as `/name`. -pub fn all_command_names_matching(prefix: &str) -> Vec { +/// +/// `workspace` is used to also scan workspace-local command directories; +/// pass `None` when no workspace context is available. +pub fn all_command_names_matching( + prefix: &str, + workspace: Option<&std::path::Path>, +) -> Vec { let prefix = prefix.strip_prefix('/').unwrap_or(prefix).to_lowercase(); let mut result: Vec = COMMANDS .iter() @@ -730,7 +736,7 @@ pub fn all_command_names_matching(prefix: &str) -> Vec { .collect(); // Add user-defined commands - result.extend(user_commands::user_commands_matching(&prefix)); + result.extend(user_commands::user_commands_matching(&prefix, workspace)); result.sort(); result.dedup(); diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 65b93c6d..d1314019 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -1,36 +1,54 @@ -//! User-defined slash commands from `~/.deepseek/commands/.md`. +//! User-defined slash commands from `~/.deepseek/commands/.md` and +//! workspace-local `/.deepseek/commands/.md`. //! -//! Users drop `.md` files into `~/.deepseek/commands/` and the filename +//! Users drop `.md` files into a commands directory and the filename //! (without `.md` extension) becomes a slash command. When invoked via //! `/name`, the file contents are sent as a user message. +//! +//! ## Precedence +//! +//! Workspace-local directories shadow user-global by name: +//! +//! 1. `/.deepseek/commands/` (project-local, highest) +//! 2. `/.claude/commands/` (Claude Code interop) +//! 3. `/.cursor/commands/` (Cursor interop) +//! 4. `~/.deepseek/commands/` (user-global, lowest) -use std::path::PathBuf; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use crate::tui::app::{App, AppAction}; use super::CommandResult; -/// Path to the user commands directory: `~/.deepseek/commands/`. -fn commands_dir() -> PathBuf { +/// Path to the global user commands directory: `~/.deepseek/commands/`. +fn global_commands_dir() -> PathBuf { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")); home.join(".deepseek").join("commands") } -/// Scan `~/.deepseek/commands/` for `.md` files and return `(name, content)` pairs. -/// -/// The name is the filename without the `.md` extension, normalized to -/// lowercase. Files that fail to read are silently skipped. The directory -/// is re-scanned on every call so newly-added commands show up immediately -/// without requiring a restart. -pub fn load_user_commands() -> Vec<(String, String)> { - let dir = commands_dir(); +/// Return all candidate commands directories in precedence order. +fn commands_dirs(workspace: Option<&Path>) -> Vec { + let mut dirs = Vec::new(); + if let Some(ws) = workspace { + dirs.push(ws.join(".deepseek").join("commands")); + dirs.push(ws.join(".claude").join("commands")); + dirs.push(ws.join(".cursor").join("commands")); + } + dirs.push(global_commands_dir()); + dirs +} + +/// Scan a single commands directory for `.md` files and return +/// `(name, content)` pairs. Errors are silently skipped. +fn load_commands_from_dir(dir: &Path) -> Vec<(String, String)> { let mut commands: Vec<(String, String)> = Vec::new(); - if !dir.exists() { + if !dir.is_dir() { return commands; } - let entries = match std::fs::read_dir(&dir) { + let entries = match std::fs::read_dir(dir) { Ok(entries) => entries, Err(_) => return commands, }; @@ -51,6 +69,27 @@ pub fn load_user_commands() -> Vec<(String, String)> { commands.push((stem, content)); } + commands +} + +/// Scan every candidate commands directory and return merged +/// `(name, content)` pairs. Workspace-local directories shadow +/// user-global by name — the first occurrence of a name wins. +/// +/// Pass `None` for the workspace to scan only the global directory +/// (backward-compatible with callers that don't have workspace context). +pub fn load_user_commands(workspace: Option<&Path>) -> Vec<(String, String)> { + let mut seen: HashSet = HashSet::new(); + let mut commands: Vec<(String, String)> = Vec::new(); + + for dir in commands_dirs(workspace) { + for (name, content) in load_commands_from_dir(&dir) { + if seen.insert(name.clone()) { + commands.push((name, content)); + } + } + } + // Sort by name for deterministic ordering. commands.sort_by(|a, b| a.0.cmp(&b.0)); commands @@ -72,13 +111,13 @@ fn apply_template(template: &str, args: &str) -> String { result } -pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option { +pub fn try_dispatch_user_command(app: &mut App, input: &str) -> Option { let parts: Vec<&str> = input.trim().splitn(2, ' ').collect(); let command = parts[0].to_lowercase(); let command = command.strip_prefix('/').unwrap_or(&command); let args = parts.get(1).copied().unwrap_or("").trim(); - let user_commands = load_user_commands(); + let user_commands = load_user_commands(Some(&app.workspace)); for (name, content) in &user_commands { if name == command { @@ -94,9 +133,12 @@ pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option Vec { +/// +/// `workspace` is used to also scan workspace-local command directories; +/// pass `None` when no workspace context is available. +pub fn user_commands_matching(prefix: &str, workspace: Option<&Path>) -> Vec { let prefix = prefix.to_lowercase(); - load_user_commands() + load_user_commands(workspace) .into_iter() .filter(|(name, _)| name.starts_with(&prefix)) .map(|(name, _)| format!("/{}", name)) @@ -106,10 +148,11 @@ pub fn user_commands_matching(prefix: &str) -> Vec { #[cfg(test)] mod tests { use super::*; + use tempfile::TempDir; #[test] - fn test_commands_dir_contains_deepseek_commands() { - let dir = commands_dir(); + fn test_global_commands_dir_contains_deepseek_commands() { + let dir = global_commands_dir(); let parts: Vec<_> = dir .components() .filter_map(|component| component.as_os_str().to_str()) @@ -124,13 +167,9 @@ mod tests { } #[test] - fn test_load_user_commands_when_dir_absent() { - // Use a temp dir that definitely doesn't have a commands dir. - let _tmp = std::env::temp_dir().join("deepseek-test-nonexistent"); - // Temporarily override the home for this test by checking the - // function with a non-existent directory path. - let cmds = load_user_commands(); - // Should not panic; returns empty vec when dir doesn't exist. + fn test_load_user_commands_when_no_dir_exists() { + let cmds = load_user_commands(None); + // Should not panic; returns empty vec when no directories exist. assert!(cmds.is_empty() || !cmds.is_empty()); } @@ -166,8 +205,162 @@ mod tests { } #[test] - fn test_user_commands_matching_with_prefix() { - let matches = user_commands_matching("zzzznotfound"); + fn test_user_commands_matching_with_prefix_no_workspace() { + let matches = user_commands_matching("zzzznotfound", None); assert!(matches.is_empty()); } + + // ── Workspace-local commands tests ───────────────────────────────── + + fn write_command(dir: &Path, name: &str, body: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join(format!("{name}.md")), body).unwrap(); + } + + #[test] + fn load_user_commands_scans_workspace_local_dir() { + let tmp = TempDir::new().unwrap(); + let ws = tmp.path(); + let cmds_dir = ws.join(".deepseek").join("commands"); + write_command(&cmds_dir, "hello", "echo hi"); + + let cmds = load_user_commands(Some(ws)); + let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect(); + assert!( + names.contains(&"hello"), + "expected 'hello' in workspace-local commands: {names:?}" + ); + } + + #[test] + fn load_user_commands_scans_claude_and_cursor_dirs() { + let tmp = TempDir::new().unwrap(); + let ws = tmp.path(); + write_command( + &ws.join(".claude").join("commands"), + "claude-cmd", + "claude body", + ); + write_command( + &ws.join(".cursor").join("commands"), + "cursor-cmd", + "cursor body", + ); + + let cmds = load_user_commands(Some(ws)); + let names: Vec<&str> = cmds.iter().map(|(n, _)| n.as_str()).collect(); + assert!( + names.contains(&"claude-cmd"), + "expected 'claude-cmd': {names:?}" + ); + assert!( + names.contains(&"cursor-cmd"), + "expected 'cursor-cmd': {names:?}" + ); + } + + #[test] + fn workspace_local_shadows_global_by_name() { + let tmp = TempDir::new().unwrap(); + let ws = tmp.path(); + + // Workspace-local version + write_command( + &ws.join(".deepseek").join("commands"), + "shared", + "workspace version", + ); + // Global version — simulate by putting it in a "global" temp dir. + // Since we can't easily override `dirs::home_dir()`, we test the + // first-match-wins semantics by putting the same name in both + // workspace-scanned dirs. The first dir in precedence order wins. + write_command( + &ws.join(".claude").join("commands"), + "shared", + "claude version", + ); + + let cmds = load_user_commands(Some(ws)); + let shared = cmds + .iter() + .find(|(n, _)| n == "shared") + .expect("shared present"); + assert_eq!( + shared.1, "workspace version", + "workspace-local (.deepseek) must shadow later dirs" + ); + } + + #[test] + fn load_user_commands_without_workspace_falls_back_to_global_only() { + // When no workspace is passed, only the global ~/.deepseek/commands/ + // is scanned. On test machines this dir often doesn't exist, so we + // just verify we don't panic. + let cmds = load_user_commands(None); + // This should not panic; can be empty or have user's real commands. + let _ = cmds; + } + + #[test] + fn try_dispatch_uses_workspace_local_command() { + use crate::config::Config; + use crate::tui::app::TuiOptions; + + let tmp = TempDir::new().unwrap(); + let ws = tmp.path().to_path_buf(); + write_command( + &ws.join(".deepseek").join("commands"), + "hello", + "Hello, $ARGUMENTS!", + ); + + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: ws.clone(), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: PathBuf::from("."), + memory_path: PathBuf::from("memory.md"), + notes_path: PathBuf::from("notes.txt"), + mcp_config_path: PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + let mut app = App::new(options, &Config::default()); + let result = try_dispatch_user_command(&mut app, "/hello world"); + assert!(result.is_some()); + let cmd_result = result.unwrap(); + match cmd_result.action { + Some(AppAction::SendMessage(msg)) => { + assert!(msg.contains("Hello, world!"), "got: {msg}"); + } + other => panic!("expected SendMessage action, got: {other:?}"), + } + } + + #[test] + fn user_commands_matching_with_workspace() { + let tmp = TempDir::new().unwrap(); + let ws = tmp.path(); + write_command( + &ws.join(".deepseek").join("commands"), + "project-cmd", + "body", + ); + + let matches = user_commands_matching("project", Some(ws)); + assert!( + matches.contains(&"/project-cmd".to_string()), + "got: {matches:?}" + ); + } } diff --git a/crates/tui/src/tui/slash_menu.rs b/crates/tui/src/tui/slash_menu.rs index ca87470a..a6ffd7c3 100644 --- a/crates/tui/src/tui/slash_menu.rs +++ b/crates/tui/src/tui/slash_menu.rs @@ -20,7 +20,13 @@ pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec bool { return false; } - let candidates = slash_completion_hints(&app.input, 128, &app.cached_skills, app.ui_locale) - .into_iter() - .map(|entry| entry.name) - .collect::>(); + let candidates = slash_completion_hints( + &app.input, + 128, + &app.cached_skills, + app.ui_locale, + Some(&app.workspace), + ) + .into_iter() + .map(|entry| entry.name) + .collect::>(); if candidates.is_empty() { return false; diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index a5da3500..4fcf47d9 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1953,6 +1953,7 @@ pub(crate) fn slash_completion_hints( limit: usize, cached_skills: &[(String, String)], locale: crate::localization::Locale, + workspace: Option<&std::path::Path>, ) -> Vec { if !input.starts_with('/') { return Vec::new(); @@ -1970,7 +1971,7 @@ pub(crate) fn slash_completion_hints( // built-in ones from the static registry and use a generic label for // user-defined commands. if completing_skill_arg.is_none() { - for name in commands::all_command_names_matching(prefix) { + for name in commands::all_command_names_matching(prefix, workspace) { let command_key = name.trim_start_matches('/'); let description = if let Some(info) = commands::get_command_info(command_key) { info.description_for(locale).to_string() @@ -2359,14 +2360,14 @@ mod tests { #[test] fn slash_completion_hints_include_links_and_config() { - let hints = slash_completion_hints("/", 128, &[], Locale::En); + let hints = slash_completion_hints("/", 128, &[], Locale::En, None); assert!(hints.iter().any(|hint| hint.name == "/config")); assert!(hints.iter().any(|hint| hint.name == "/links")); } #[test] fn slash_completion_hints_exclude_set_and_deepseek_commands() { - let hints = slash_completion_hints("/", 128, &[], Locale::En); + let hints = slash_completion_hints("/", 128, &[], Locale::En, None); assert!(!hints.iter().any(|hint| hint.name == "/set")); assert!(!hints.iter().any(|hint| hint.name == "/deepseek")); } @@ -2377,7 +2378,7 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En); + let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En, None); assert!( hints .iter() @@ -2396,7 +2397,7 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En); + let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En, None); assert!( hints .iter() @@ -2411,7 +2412,7 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En); + let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En, None); assert_eq!(hints.len(), 1); assert_eq!(hints[0].name, "/skill my-review"); assert!(hints[0].is_skill);