aether-wisp 0.1.5

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use tui::{
    Combobox, Component, Event, Frame, Line, PickerMessage, Searchable, Style, ViewContext, display_width_text,
    pad_text_to_width, truncate_text,
};

#[derive(Debug, Clone)]
pub struct CommandEntry {
    pub name: String,
    pub description: String,
    pub has_input: bool,
    pub hint: Option<String>,
    pub builtin: bool,
}

impl Searchable for CommandEntry {
    fn search_text(&self) -> String {
        format!("{} {}", self.name, self.description)
    }
}

#[doc = include_str!("../docs/command_picker.md")]
pub struct CommandPicker {
    combobox: Combobox<CommandEntry>,
}

pub type CommandPickerMessage = PickerMessage<CommandEntry>;

impl CommandPicker {
    pub fn new(commands: Vec<CommandEntry>) -> Self {
        Self { combobox: Combobox::new(commands) }
    }

    #[cfg(test)]
    pub fn query(&self) -> &str {
        self.combobox.query()
    }
}

impl Component for CommandPicker {
    type Message = CommandPickerMessage;

    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
        self.combobox.handle_picker_event(event)
    }

    fn render(&mut self, context: &ViewContext) -> Frame {
        let mut lines = Vec::new();

        if self.combobox.is_empty() {
            lines.push(Line::new("  (no matching commands)".to_string()));
            return Frame::new(lines);
        }

        let max_name_width =
            self.combobox.matches().iter().map(|cmd| display_width_text(&format!("/{}", cmd.name))).max().unwrap_or(0);

        let item_lines = self.combobox.render_items(context, |command, is_selected, ctx| {
            let hint_suffix = match &command.hint {
                Some(hint) => format!("  [{hint}]"),
                None => String::new(),
            };

            let name_part = format!("/{}", command.name);
            let padded_name = pad_text_to_width(&name_part, max_name_width);
            let line_text = format!("{padded_name}  {}{}", command.description, hint_suffix);

            let max_width = ctx.size.width as usize;
            let truncated = truncate_text(&line_text, max_width);

            if is_selected {
                ctx.theme.selected_row_line(truncated)
            } else {
                build_styled_command_line(&truncated, padded_name.len(), ctx.theme.muted())
            }
        });
        lines.extend(item_lines);

        Frame::new(lines)
    }
}

fn build_styled_command_line(truncated: &str, name_byte_len: usize, muted: tui::Color) -> Line {
    if truncated.len() <= name_byte_len {
        Line::new(truncated)
    } else {
        let mut line = Line::new(&truncated[..name_byte_len]);
        line.push_with_style(&truncated[name_byte_len..], Style::fg(muted));
        line
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tui::test_picker::type_query;
    use tui::{KeyCode, KeyEvent, KeyModifiers};

    fn key(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    fn sample_commands() -> Vec<CommandEntry> {
        vec![
            CommandEntry {
                name: "settings".into(),
                description: "Open settings".into(),
                has_input: false,
                hint: None,
                builtin: true,
            },
            CommandEntry {
                name: "search".into(),
                description: "Search code in the project".into(),
                has_input: true,
                hint: Some("query pattern".into()),
                builtin: false,
            },
            CommandEntry {
                name: "web".into(),
                description: "Browse the web".into(),
                has_input: true,
                hint: Some("url".into()),
                builtin: false,
            },
        ]
    }

    #[tokio::test]
    async fn handle_key_enter_returns_selected_command() {
        let mut picker = CommandPicker::new(sample_commands());

        let outcome = picker.on_event(&Event::Key(key(KeyCode::Enter))).await;

        assert!(outcome.is_some());
        assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::Confirm(_)]));
    }

    #[tokio::test]
    async fn handle_key_backspace_on_empty_query_requests_close() {
        let mut picker = CommandPicker::new(sample_commands());

        let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;

        assert!(outcome.is_some());

        assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CloseAndPopChar]));
    }

    #[tokio::test]
    async fn handle_key_char_returns_char_typed() {
        let mut picker = CommandPicker::new(sample_commands());

        let outcome = picker.on_event(&Event::Key(key(KeyCode::Char('r')))).await;

        assert!(outcome.is_some());

        assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CharTyped('r')]));
        assert_eq!(picker.query(), "r");
    }

    #[tokio::test]
    async fn handle_key_whitespace_closes_picker() {
        let mut picker = CommandPicker::new(sample_commands());

        let outcome = picker.on_event(&Event::Key(key(KeyCode::Char(' ')))).await;

        assert!(outcome.is_some());

        assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::CloseWithChar(' ')]));
    }

    #[tokio::test]
    async fn handle_key_backspace_with_query_returns_pop_char() {
        let mut picker = CommandPicker::new(sample_commands());
        type_query(&mut picker, "co").await;

        let outcome = picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;

        assert!(outcome.is_some());

        assert!(matches!(outcome.unwrap().as_slice(), [PickerMessage::PopChar]));
        assert_eq!(picker.query(), "c");
    }

    #[tokio::test]
    async fn type_and_delete_updates_query() {
        let mut picker = CommandPicker::new(sample_commands());
        type_query(&mut picker, "co").await;
        assert_eq!(picker.query(), "co");

        picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
        assert_eq!(picker.query(), "c");

        picker.on_event(&Event::Key(key(KeyCode::Backspace))).await;
        assert_eq!(picker.query(), "");
    }
}