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