1use once_cell::sync::Lazy;
2
3use crate::skills::command_skill_specs;
4use crate::terminal_setup::detector::TerminalType;
5use crate::ui::search::{fuzzy_match, normalize_query};
6
7#[derive(Clone, Copy, Debug)]
9pub struct SlashCommandInfo {
10 pub name: &'static str,
12 pub description: &'static str,
14}
15
16pub static SLASH_COMMANDS: Lazy<Vec<SlashCommandInfo>> = Lazy::new(|| {
18 command_skill_specs()
19 .iter()
20 .map(|spec| SlashCommandInfo {
21 name: spec.slash_name,
22 description: spec.description,
23 })
24 .collect()
25});
26
27fn detected_terminal_for_visibility() -> TerminalType {
28 TerminalType::detect().unwrap_or(TerminalType::Unknown)
29}
30
31fn command_visible_for_terminal(command: &SlashCommandInfo, terminal: TerminalType) -> bool {
32 command.name != "terminal-setup" || terminal.should_offer_terminal_setup()
33}
34
35pub fn visible_commands() -> Vec<&'static SlashCommandInfo> {
37 visible_commands_for_terminal(detected_terminal_for_visibility())
38}
39
40pub fn visible_commands_for_terminal(terminal: TerminalType) -> Vec<&'static SlashCommandInfo> {
42 SLASH_COMMANDS
43 .iter()
44 .filter(|command| command_visible_for_terminal(command, terminal))
45 .collect()
46}
47
48pub fn find_visible_command(name: &str) -> Option<&'static SlashCommandInfo> {
50 let terminal = detected_terminal_for_visibility();
51 SLASH_COMMANDS
52 .iter()
53 .find(|command| command.name == name && command_visible_for_terminal(command, terminal))
54}
55
56pub fn find_command(name: &str) -> Option<&'static SlashCommandInfo> {
58 SLASH_COMMANDS.iter().find(|command| command.name == name)
59}
60
61pub fn suggestions_for_terminal(prefix: &str, terminal: TerminalType) -> Vec<&'static SlashCommandInfo> {
63 let visible = visible_commands_for_terminal(terminal);
64 suggestions_for_commands(prefix, &visible)
65}
66
67fn suggestions_for_commands(prefix: &str, commands: &[&'static SlashCommandInfo]) -> Vec<&'static SlashCommandInfo> {
68 let trimmed = prefix.trim();
69 if trimmed.is_empty() {
70 return commands.to_vec();
71 }
72
73 let query = trimmed.to_ascii_lowercase();
74
75 let mut prefix_matches: Vec<&SlashCommandInfo> =
76 commands.iter().copied().filter(|info| info.name.starts_with(&query)).collect();
77
78 if !prefix_matches.is_empty() {
79 prefix_matches.sort_by(|a, b| a.name.cmp(b.name));
80 return prefix_matches;
81 }
82
83 let mut substring_matches: Vec<(&SlashCommandInfo, usize)> = commands
84 .iter()
85 .copied()
86 .filter_map(|info| info.name.find(&query).map(|position| (info, position)))
87 .collect();
88
89 if !substring_matches.is_empty() {
90 substring_matches
91 .sort_by(|(a, pos_a), (b, pos_b)| (*pos_a, a.name.len(), a.name).cmp(&(*pos_b, b.name.len(), b.name)));
92 return substring_matches.into_iter().map(|(info, _)| info).collect();
93 }
94
95 let normalized_query = normalize_query(&query);
96 if normalized_query.is_empty() {
97 return commands.to_vec();
98 }
99
100 let mut scored: Vec<(&SlashCommandInfo, usize, usize)> = commands
101 .iter()
102 .copied()
103 .filter_map(|info| {
104 let mut candidate = info.name.to_ascii_lowercase();
112 let name_portion_end = candidate.len();
113 let desc_lower = if info.description.is_empty() {
114 String::new()
115 } else {
116 info.description.to_ascii_lowercase()
117 };
118 if !desc_lower.is_empty() {
119 candidate.push(' ');
120 candidate.push_str(&desc_lower);
121 }
122
123 if !fuzzy_match(&normalized_query, &candidate) {
124 return None;
125 }
126
127 let name_pos = candidate[..name_portion_end].find(&query).unwrap_or(usize::MAX);
128 let desc_pos = desc_lower.find(&query).unwrap_or(usize::MAX);
129
130 Some((info, name_pos, desc_pos))
131 })
132 .collect();
133
134 if scored.is_empty() {
135 return commands.to_vec();
136 }
137
138 scored.sort_by(|(a, name_pos_a, desc_pos_a), (b, name_pos_b, desc_pos_b)| {
139 let score_a = (*name_pos_a == usize::MAX, *name_pos_a, *desc_pos_a, a.name);
140 let score_b = (*name_pos_b == usize::MAX, *name_pos_b, *desc_pos_b, b.name);
141 score_a.cmp(&score_b)
142 });
143
144 scored.into_iter().map(|(info, _, _)| info).collect()
145}
146
147pub fn suggestions_for(prefix: &str) -> Vec<&'static SlashCommandInfo> {
149 suggestions_for_terminal(prefix, detected_terminal_for_visibility())
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 fn names_for(prefix: &str) -> Vec<&'static str> {
157 suggestions_for(prefix).into_iter().map(|info| info.name).collect()
158 }
159
160 #[test]
161 fn prefix_matches_are_sorted_alphabetically() {
162 let names = names_for("c");
163 assert_eq!(names, vec!["clear", "compact", "config", "copy"]);
164 }
165
166 #[test]
167 fn substring_matches_prioritize_earlier_occurrences() {
168 let names = names_for("eme");
169 assert_eq!(names, vec!["theme"]);
170 }
171
172 #[test]
173 fn fuzzy_matches_include_description_keywords() {
174 let names = names_for("check");
175 assert!(names.contains(&"config"));
176 }
177
178 #[test]
179 fn fuzzy_matches_handle_non_contiguous_sequences() {
180 let names = names_for("sts");
181 assert!(names.contains(&"status"));
182 }
183
184 #[test]
185 fn prefix_matches_include_history_command() {
186 let names = names_for("his");
187 assert_eq!(names, vec!["history"]);
188 }
189
190 #[test]
191 fn prefix_matches_include_review_command() {
192 let names = names_for("rev");
193 assert_eq!(names, vec!["review"]);
194 }
195
196 #[test]
197 fn checkpoint_commands_are_visible_and_suggested() {
198 let visible = visible_commands_for_terminal(TerminalType::Unknown);
199 let suggestions = suggestions_for_terminal("re", TerminalType::Unknown);
200 for name in ["rewind", "redo"] {
201 assert!(visible.iter().any(|command| command.name == name), "{name} must be visible");
202 assert!(suggestions.iter().any(|command| command.name == name), "{name} must be suggested");
203 assert_eq!(find_command(name).expect("checkpoint command").name, name);
204 }
205 assert!(find_command("rewind-recover").is_none(), "rewind-recover must be removed; /rewind auto-recovers");
206 assert_eq!(names_for("redo"), vec!["redo"]);
207 }
208
209 #[test]
210 fn consolidated_panel_commands_route_via_config() {
211 assert!(names_for("task").is_empty() || !names_for("task").contains(&"tasks"));
212 assert!(names_for("job").is_empty() || !names_for("job").contains(&"jobs"));
213 assert!(find_command("config").is_some());
214 }
215
216 #[test]
217 fn terminal_setup_hidden_for_native_terminals() {
218 let names: Vec<&str> = visible_commands_for_terminal(TerminalType::WezTerm)
219 .into_iter()
220 .map(|info| info.name)
221 .collect();
222 assert!(!names.contains(&"terminal-setup"));
223
224 let names: Vec<&str> = visible_commands_for_terminal(TerminalType::ITerm2)
225 .into_iter()
226 .map(|info| info.name)
227 .collect();
228 assert!(!names.contains(&"terminal-setup"));
229
230 let names: Vec<&str> = visible_commands_for_terminal(TerminalType::WindowsTerminal)
231 .into_iter()
232 .map(|info| info.name)
233 .collect();
234 assert!(!names.contains(&"terminal-setup"));
235 }
236
237 #[test]
238 fn terminal_setup_visible_for_supported_setup_terminals() {
239 let names: Vec<&str> = visible_commands_for_terminal(TerminalType::VSCode)
240 .into_iter()
241 .map(|info| info.name)
242 .collect();
243 assert!(names.contains(&"terminal-setup"));
244
245 let names: Vec<&str> = visible_commands_for_terminal(TerminalType::Alacritty)
246 .into_iter()
247 .map(|info| info.name)
248 .collect();
249 assert!(names.contains(&"terminal-setup"));
250
251 let names: Vec<&str> = visible_commands_for_terminal(TerminalType::Zed)
252 .into_iter()
253 .map(|info| info.name)
254 .collect();
255 assert!(names.contains(&"terminal-setup"));
256 }
257
258 #[test]
259 fn config_consolidated_commands_are_not_registered() {
260 for removed in [
261 "checkup",
262 "permissions",
263 "ide",
264 "tasks",
265 "jobs",
266 "log",
267 "subprocess",
268 "notify",
269 ] {
270 assert!(find_command(removed).is_none(), "/{removed} must route via /config");
271 }
272 assert!(find_command("config").is_some());
273 }
274}