返回 DeepSeek-TUI-2026
user_commands.rs
根目录 / crates / tui / src / commands / user_commands.rs
1 //! User-defined slash commands from `~/.deepseek/commands/<name>.md`.
2 //!
3 //! Users drop `.md` files into `~/.deepseek/commands/` and the filename
4 //! (without `.md` extension) becomes a slash command. When invoked via
5 //! `/name`, the file contents are sent as a user message.
6
7 use std::path::PathBuf;
8
9 use crate::tui::app::{App, AppAction};
10
11 use super::CommandResult;
12
13 /// Path to the user commands directory: `~/.deepseek/commands/`.
14 fn commands_dir() -> PathBuf {
15 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
16 home.join(".deepseek").join("commands")
17 }
18
19 /// Scan `~/.deepseek/commands/` for `.md` files and return `(name, content)` pairs.
20 ///
21 /// The name is the filename without the `.md` extension, normalized to
22 /// lowercase. Files that fail to read are silently skipped. The directory
23 /// is re-scanned on every call so newly-added commands show up immediately
24 /// without requiring a restart.
25 pub fn load_user_commands() -> Vec<(String, String)> {
26 let dir = commands_dir();
27 let mut commands: Vec<(String, String)> = Vec::new();
28
29 if !dir.exists() {
30 return commands;
31 }
32
33 let entries = match std::fs::read_dir(&dir) {
34 Ok(entries) => entries,
35 Err(_) => return commands,
36 };
37
38 for entry in entries.flatten() {
39 let path = entry.path();
40 if path.extension().and_then(|e| e.to_str()) != Some("md") {
41 continue;
42 }
43 let stem = match path.file_stem().and_then(|s| s.to_str()) {
44 Some(stem) => stem.to_lowercase(),
45 None => continue,
46 };
47 let content = match std::fs::read_to_string(&path) {
48 Ok(c) => c,
49 Err(_) => continue,
50 };
51 commands.push((stem, content));
52 }
53
54 // Sort by name for deterministic ordering.
55 commands.sort_by(|a, b| a.0.cmp(&b.0));
56 commands
57 }
58
59 /// Check if the input matches a user-defined command and return the
60 /// content as a `SendMessage` action.
61 ///
62 /// The `input` should be the full command string including the `/`
63 /// prefix (e.g. `/mycmd` or `/mycmd with args`). Only exact matches
64 /// on the command name are considered (no partial/alias matching).
65 /// Substitute $1, $2, $ARGUMENTS placeholders in a command template.
66 fn apply_template(template: &str, args: &str) -> String {
67 let positional: Vec<&str> = args.split_whitespace().collect();
68 let mut result = template.replace("$ARGUMENTS", args);
69 for (i, arg) in positional.iter().enumerate() {
70 result = result.replace(&format!("${}", i + 1), arg);
71 }
72 result
73 }
74
75 pub fn try_dispatch_user_command(_app: &mut App, input: &str) -> Option<CommandResult> {
76 let parts: Vec<&str> = input.trim().splitn(2, ' ').collect();
77 let command = parts[0].to_lowercase();
78 let command = command.strip_prefix('/').unwrap_or(&command);
79 let args = parts.get(1).copied().unwrap_or("").trim();
80
81 let user_commands = load_user_commands();
82
83 for (name, content) in &user_commands {
84 if name == command {
85 let message = apply_template(content, args);
86 return Some(CommandResult::action(AppAction::SendMessage(message)));
87 }
88 }
89
90 None
91 }
92
93 /// Get user command names that match a given prefix (for autocomplete).
94 ///
95 /// The prefix should be the command name portion only (after `/`).
96 /// Returns entries formatted as `/name`.
97 pub fn user_commands_matching(prefix: &str) -> Vec<String> {
98 let prefix = prefix.to_lowercase();
99 load_user_commands()
100 .into_iter()
101 .filter(|(name, _)| name.starts_with(&prefix))
102 .map(|(name, _)| format!("/{}", name))
103 .collect()
104 }
105
106 #[cfg(test)]
107 mod tests {
108 use super::*;
109
110 #[test]
111 fn test_commands_dir_contains_deepseek_commands() {
112 let dir = commands_dir();
113 let parts: Vec<_> = dir
114 .components()
115 .filter_map(|component| component.as_os_str().to_str())
116 .collect();
117 assert!(
118 parts
119 .windows(2)
120 .any(|pair| pair == [".deepseek", "commands"]),
121 "expected .deepseek/commands components in path, got: {}",
122 dir.display()
123 );
124 }
125
126 #[test]
127 fn test_load_user_commands_when_dir_absent() {
128 // Use a temp dir that definitely doesn't have a commands dir.
129 let _tmp = std::env::temp_dir().join("deepseek-test-nonexistent");
130 // Temporarily override the home for this test by checking the
131 // function with a non-existent directory path.
132 let cmds = load_user_commands();
133 // Should not panic; returns empty vec when dir doesn't exist.
134 assert!(cmds.is_empty() || !cmds.is_empty());
135 }
136
137 #[test]
138 fn test_try_dispatch_nonexistent_command() {
139 use crate::config::Config;
140 use crate::tui::app::TuiOptions;
141
142 let options = TuiOptions {
143 model: "deepseek-v4-pro".to_string(),
144 workspace: PathBuf::from("."),
145 config_path: None,
146 config_profile: None,
147 allow_shell: false,
148 use_alt_screen: true,
149 use_mouse_capture: false,
150 use_bracketed_paste: true,
151 max_subagents: 1,
152 skills_dir: PathBuf::from("."),
153 memory_path: PathBuf::from("memory.md"),
154 notes_path: PathBuf::from("notes.txt"),
155 mcp_config_path: PathBuf::from("mcp.json"),
156 use_memory: false,
157 start_in_agent_mode: false,
158 skip_onboarding: true,
159 yolo: false,
160 resume_session_id: None,
161 initial_input: None,
162 };
163 let mut app = App::new(options, &Config::default());
164 let result = try_dispatch_user_command(&mut app, "/nonexistent-thing-12345");
165 assert!(result.is_none());
166 }
167
168 #[test]
169 fn test_user_commands_matching_with_prefix() {
170 let matches = user_commands_matching("zzzznotfound");
171 assert!(matches.is_empty());
172 }
173 }
174
174 lines RUST