Skip to main content

koda_cli/widgets/
slash_menu.rs

1//! Slash command dropdown — thin wrapper around the generic dropdown.
2//!
3//! Appears when the user types `/` in an empty input. Filters live
4//! as the user continues typing.
5
6use super::dropdown::{self, DropdownItem, DropdownState};
7use ratatui::text::Line;
8
9/// A slash command item.
10#[derive(Clone, Debug)]
11pub struct SlashCommand {
12    pub command: &'static str,
13    pub description: &'static str,
14    /// Argument placeholder shown when the command needs user input (e.g. `Some("<name>")`)
15    /// `None` for self-contained commands and picker-openers.
16    pub arg_hint: Option<&'static str>,
17}
18
19impl DropdownItem for SlashCommand {
20    fn label(&self) -> &str {
21        self.command
22    }
23    fn description(&self) -> String {
24        self.description.to_string()
25    }
26    fn matches_filter(&self, filter: &str) -> bool {
27        self.command.starts_with(filter)
28    }
29}
30
31/// Create a slash menu dropdown from the command list and current input.
32/// Returns `None` if no commands match.
33pub fn from_input(
34    commands: &'static [(&'static str, &'static str, Option<&'static str>)],
35    input: &str,
36) -> Option<DropdownState<SlashCommand>> {
37    let items: Vec<SlashCommand> = commands
38        .iter()
39        .map(|(cmd, desc, arg_hint)| SlashCommand {
40            command: cmd,
41            description: desc,
42            arg_hint: *arg_hint,
43        })
44        .collect();
45    let mut dd = DropdownState::new(items, "\u{1f43b} Commands");
46    if dd.apply_filter(input) {
47        Some(dd)
48    } else {
49        None
50    }
51}
52
53/// Build lines for rendering. Delegates to the generic dropdown renderer.
54pub fn build_menu_lines(state: &DropdownState<SlashCommand>) -> Vec<Line<'static>> {
55    dropdown::build_dropdown_lines(state)
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    const TEST_COMMANDS: &[(&str, &str, Option<&str>)] = &[
63        ("/agent", "Agents", Some("<name>")),
64        ("/compact", "Compact", None),
65        ("/diff", "Diff", None),
66        ("/exit", "Quit", None),
67        ("/expand", "Expand", None),
68        ("/model", "Pick model", None),
69    ];
70
71    #[test]
72    fn from_input_all() {
73        let state = from_input(TEST_COMMANDS, "/").unwrap();
74        assert_eq!(state.filtered.len(), 6);
75    }
76
77    #[test]
78    fn from_input_filtered() {
79        let state = from_input(TEST_COMMANDS, "/m").unwrap();
80        assert_eq!(state.filtered.len(), 1);
81        assert_eq!(state.filtered[0].command, "/model");
82    }
83
84    #[test]
85    fn from_input_no_match() {
86        assert!(from_input(TEST_COMMANDS, "/z").is_none());
87    }
88
89    #[test]
90    fn selected_command() {
91        let state = from_input(TEST_COMMANDS, "/").unwrap();
92        assert_eq!(state.selected_item().unwrap().command, "/agent");
93    }
94}