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    /// Command name typed after the `/` prefix (e.g. `"compact"`).
11    pub name: &'static str,
12    /// Short human-readable description shown in autocomplete.
13    pub description: &'static str,
14}
15
16/// Collection of slash command definitions in the order they should be displayed.
17pub 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
35/// Return all slash commands visible for the auto-detected terminal.
36pub fn visible_commands() -> Vec<&'static SlashCommandInfo> {
37    visible_commands_for_terminal(detected_terminal_for_visibility())
38}
39
40/// Return slash commands visible for the given terminal type.
41pub 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
48/// Find a visible slash command by exact name for the auto-detected terminal.
49pub 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
56/// Find a slash command by exact name, regardless of terminal visibility.
57pub fn find_command(name: &str) -> Option<&'static SlashCommandInfo> {
58    SLASH_COMMANDS.iter().find(|command| command.name == name)
59}
60
61/// Return fuzzy-matched slash command suggestions for the given terminal.
62pub 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            // The command set is static `&'static str`, but each lowercased
105            // form was previously recomputed up to 4x per candidate per
106            // keystroke (name + description, once for the fuzzy candidate and
107            // again for position lookups). Build the candidate once from the
108            // lowercased name, remember where the name portion ends, and reuse
109            // that slice plus the lowercased description for positions — two
110            // allocations per candidate instead of four.
111            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
147/// Returns slash command metadata that match the provided prefix (case insensitive).
148pub 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}