Skip to main content

vtcode_core/ui/
slash.rs

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/// Metadata describing a slash command supported by the chat interface.
8#[derive(Clone, Copy, Debug)]
9pub struct SlashCommandInfo {
10    pub name: &'static str,
11    pub description: &'static str,
12}
13
14/// Collection of slash command definitions in the order they should be displayed.
15pub 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
148/// Returns slash command metadata that match the provided prefix (case insensitive).
149pub 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}