aether-wisp 0.1.7

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use tui::{KeyCode, KeyEvent, KeyModifiers};

#[doc = include_str!("docs/key_binding.md")]
#[derive(Clone, Debug)]
pub struct KeyBinding {
    pub code: KeyCode,
    pub modifiers: KeyModifiers,
}

impl KeyBinding {
    pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
        Self { code, modifiers }
    }

    pub fn matches(&self, event: KeyEvent) -> bool {
        self.code == event.code && event.modifiers.contains(self.modifiers)
    }

    pub fn char(&self) -> Option<char> {
        match self.code {
            KeyCode::Char(c) => Some(c),
            _ => None,
        }
    }
}

#[doc = include_str!("docs/keybindings.md")]
#[derive(Clone, Debug)]
pub struct Keybindings {
    pub exit: KeyBinding,
    pub cancel: KeyBinding,
    pub cycle_reasoning: KeyBinding,
    pub cycle_mode: KeyBinding,
    pub submit: KeyBinding,
    pub open_command_picker: KeyBinding,
    pub open_file_picker: KeyBinding,
    pub toggle_git_diff: KeyBinding,
}

impl Default for Keybindings {
    fn default() -> Self {
        Self {
            exit: KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
            cancel: KeyBinding::new(KeyCode::Esc, KeyModifiers::NONE),
            cycle_reasoning: KeyBinding::new(KeyCode::Tab, KeyModifiers::NONE),
            cycle_mode: KeyBinding::new(KeyCode::BackTab, KeyModifiers::NONE),
            submit: KeyBinding::new(KeyCode::Enter, KeyModifiers::NONE),
            open_command_picker: KeyBinding::new(KeyCode::Char('/'), KeyModifiers::NONE),
            open_file_picker: KeyBinding::new(KeyCode::Char('@'), KeyModifiers::NONE),
            toggle_git_diff: KeyBinding::new(KeyCode::Char('g'), KeyModifiers::CONTROL),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

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

    #[test]
    fn matches_simple_key() {
        let binding = KeyBinding::new(KeyCode::Tab, KeyModifiers::NONE);
        assert!(binding.matches(key(KeyCode::Tab)));
        assert!(!binding.matches(key(KeyCode::Enter)));
    }

    #[test]
    fn matches_key_with_modifier() {
        let binding = KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert!(binding.matches(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
        assert!(!binding.matches(key(KeyCode::Char('c'))));
    }

    #[test]
    fn matches_ignores_extra_modifiers() {
        let binding = KeyBinding::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL | KeyModifiers::SHIFT);
        assert!(binding.matches(event));
    }

    #[test]
    fn char_returns_char_for_char_key() {
        let binding = KeyBinding::new(KeyCode::Char('/'), KeyModifiers::NONE);
        assert_eq!(binding.char(), Some('/'));
    }

    #[test]
    fn char_returns_none_for_non_char_key() {
        let binding = KeyBinding::new(KeyCode::Enter, KeyModifiers::NONE);
        assert_eq!(binding.char(), None);
    }

    #[test]
    fn default_keybindings_have_expected_values() {
        let kb = Keybindings::default();
        assert_eq!(kb.exit.code, KeyCode::Char('c'));
        assert_eq!(kb.exit.modifiers, KeyModifiers::CONTROL);
        assert_eq!(kb.cancel.code, KeyCode::Esc);
        assert_eq!(kb.cycle_reasoning.code, KeyCode::Tab);
        assert_eq!(kb.cycle_mode.code, KeyCode::BackTab);
        assert_eq!(kb.submit.code, KeyCode::Enter);
        assert_eq!(kb.open_command_picker.code, KeyCode::Char('/'));
        assert_eq!(kb.open_file_picker.code, KeyCode::Char('@'));
        assert_eq!(kb.toggle_git_diff.code, KeyCode::Char('g'));
        assert_eq!(kb.toggle_git_diff.modifiers, KeyModifiers::CONTROL);
    }
}