Skip to main content

vtcode_tui/core_tui/session/
action.rs

1use hashbrown::HashMap;
2use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3
4/// Rebindable user-facing actions.
5///
6/// Each variant corresponds to a command-level action that a user may want to
7/// remap.  Fine-grained editing shortcuts (character insertion, cursor movement,
8/// text selection, Backspace, Delete, Home/End, Ctrl+A/E/W/U/K, Enter/Tab/Esc
9/// with their context-sensitive logic) remain hardcoded in `events.rs`.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum Action {
12    Interrupt,
13    Exit,
14    BackgroundOperation,
15    OpenModelPicker,
16    ClearScreen,
17    ToggleMode,
18    ScrollPageUp,
19    ScrollPageDown,
20    EditQueue,
21    HistoryPrevious,
22    HistoryNext,
23    ToggleLogs,
24    GeneratePromptSuggestion,
25}
26
27impl Action {
28    /// Human-readable name for config file serialization.
29    pub fn name(self) -> &'static str {
30        match self {
31            Action::Interrupt => "interrupt",
32            Action::Exit => "exit",
33            Action::BackgroundOperation => "background_operation",
34            Action::OpenModelPicker => "open_model_picker",
35            Action::ClearScreen => "clear_screen",
36            Action::ToggleMode => "toggle_mode",
37            Action::ScrollPageUp => "scroll_page_up",
38            Action::ScrollPageDown => "scroll_page_down",
39            Action::EditQueue => "edit_queue",
40            Action::HistoryPrevious => "history_previous",
41            Action::HistoryNext => "history_next",
42            Action::ToggleLogs => "toggle_logs",
43            Action::GeneratePromptSuggestion => "generate_prompt_suggestion",
44        }
45    }
46
47    /// All defined actions.
48    pub fn all() -> &'static [Action] {
49        &[
50            Action::Interrupt,
51            Action::Exit,
52            Action::BackgroundOperation,
53            Action::OpenModelPicker,
54            Action::ClearScreen,
55            Action::ToggleMode,
56            Action::ScrollPageUp,
57            Action::ScrollPageDown,
58            Action::EditQueue,
59            Action::HistoryPrevious,
60            Action::HistoryNext,
61            Action::ToggleLogs,
62            Action::GeneratePromptSuggestion,
63        ]
64    }
65
66    /// Look up an action by its serialized name.
67    pub fn from_name(name: &str) -> Option<Self> {
68        Self::all().iter().find(|a| a.name() == name).copied()
69    }
70}
71
72/// Parse a key binding spec like `"ctrl+c"`, `"alt+shift+enter"`, `"pageup"`.
73///
74/// Supported modifiers: `ctrl`, `shift`, `alt`, `meta`, `cmd`, `super`.
75/// Key names: single characters (`a`, `?`), `enter`, `tab`, `backtab`, `esc`,
76/// `backspace`, `delete`, `space`, `up`, `down`, `left`, `right`, `pageup`,
77/// `pagedown`, `home`, `end`, `f1`…`f12`.
78pub fn parse_key_binding(s: &str) -> Option<(KeyCode, KeyModifiers)> {
79    let s = s.trim();
80    if s.is_empty() {
81        return None;
82    }
83
84    let parts: Vec<&str> = s.split('+').collect();
85    let (modifiers, key_part) = if parts.len() == 1 {
86        (KeyModifiers::empty(), parts[0])
87    } else {
88        let mut mods = KeyModifiers::empty();
89        for part in &parts[..parts.len() - 1] {
90            match *part {
91                "ctrl" | "control" => mods.insert(KeyModifiers::CONTROL),
92                "shift" => mods.insert(KeyModifiers::SHIFT),
93                "alt" | "option" => mods.insert(KeyModifiers::ALT),
94                "meta" => mods.insert(KeyModifiers::META),
95                "cmd" | "command" | "super" | "gui" | "win" => {
96                    mods.insert(KeyModifiers::SUPER);
97                }
98                _ => return None,
99            }
100        }
101        (mods, parts[parts.len() - 1])
102    };
103
104    let code = match key_part {
105        "enter" => KeyCode::Enter,
106        "tab" => KeyCode::Tab,
107        "backtab" => KeyCode::BackTab,
108        "esc" | "escape" => KeyCode::Esc,
109        "backspace" => KeyCode::Backspace,
110        "delete" => KeyCode::Delete,
111        "space" => KeyCode::Char(' '),
112        "up" => KeyCode::Up,
113        "down" => KeyCode::Down,
114        "left" => KeyCode::Left,
115        "right" => KeyCode::Right,
116        "pageup" => KeyCode::PageUp,
117        "pagedown" => KeyCode::PageDown,
118        "home" => KeyCode::Home,
119        "end" => KeyCode::End,
120        "insert" => KeyCode::Insert,
121        "null" => KeyCode::Null,
122        "capslock" => KeyCode::CapsLock,
123        "scrolllock" => KeyCode::ScrollLock,
124        "numlock" => KeyCode::NumLock,
125        "printscreen" => KeyCode::PrintScreen,
126        "pause" => KeyCode::Pause,
127        "menu" => KeyCode::Menu,
128        name if name.starts_with('f') && name.len() > 1 => {
129            let n: u8 = name[1..].parse().ok()?;
130            match n {
131                1 => KeyCode::F(1),
132                2 => KeyCode::F(2),
133                3 => KeyCode::F(3),
134                4 => KeyCode::F(4),
135                5 => KeyCode::F(5),
136                6 => KeyCode::F(6),
137                7 => KeyCode::F(7),
138                8 => KeyCode::F(8),
139                9 => KeyCode::F(9),
140                10 => KeyCode::F(10),
141                11 => KeyCode::F(11),
142                12 => KeyCode::F(12),
143                _ => return None,
144            }
145        }
146        ch => {
147            let chars: Vec<char> = ch.chars().collect();
148            if chars.len() == 1 {
149                KeyCode::Char(chars[0])
150            } else {
151                return None;
152            }
153        }
154    };
155
156    Some((code, modifiers))
157}
158
159/// Default key → action mappings matching the current hardcoded dispatch.
160fn default_bindings() -> HashMap<Action, Vec<(KeyCode, KeyModifiers)>> {
161    use Action::*;
162    let mut m = HashMap::new();
163
164    m.insert(
165        Interrupt,
166        vec![
167            (KeyCode::Char('c'), KeyModifiers::CONTROL),
168            (KeyCode::Char('C'), KeyModifiers::CONTROL),
169            (KeyCode::Char('\u{3}'), KeyModifiers::empty()),
170        ],
171    );
172    m.insert(
173        Exit,
174        vec![
175            (KeyCode::Char('d'), KeyModifiers::CONTROL),
176            (KeyCode::Char('D'), KeyModifiers::CONTROL),
177        ],
178    );
179    m.insert(
180        BackgroundOperation,
181        vec![
182            (KeyCode::Char('b'), KeyModifiers::CONTROL),
183            (KeyCode::Char('B'), KeyModifiers::CONTROL),
184        ],
185    );
186    m.insert(
187        OpenModelPicker,
188        vec![
189            (KeyCode::Char('m'), KeyModifiers::CONTROL),
190            (KeyCode::Char('M'), KeyModifiers::CONTROL),
191        ],
192    );
193    m.insert(
194        ClearScreen,
195        vec![
196            (KeyCode::Char('l'), KeyModifiers::CONTROL),
197            (KeyCode::Char('L'), KeyModifiers::CONTROL),
198        ],
199    );
200    m.insert(ToggleMode, vec![(KeyCode::BackTab, KeyModifiers::empty())]);
201    m.insert(ScrollPageUp, vec![(KeyCode::PageUp, KeyModifiers::empty())]);
202    m.insert(
203        ScrollPageDown,
204        vec![(KeyCode::PageDown, KeyModifiers::empty())],
205    );
206
207    m.insert(
208        EditQueue,
209        vec![
210            (KeyCode::Up, KeyModifiers::ALT),
211            (KeyCode::Up, KeyModifiers::META),
212        ],
213    );
214
215    m.insert(HistoryPrevious, vec![(KeyCode::Up, KeyModifiers::empty())]);
216    m.insert(HistoryNext, vec![(KeyCode::Down, KeyModifiers::empty())]);
217
218    m.insert(
219        ToggleLogs,
220        vec![
221            (KeyCode::Char('t'), KeyModifiers::CONTROL),
222            (KeyCode::Char('T'), KeyModifiers::CONTROL),
223        ],
224    );
225    m.insert(
226        GeneratePromptSuggestion,
227        vec![
228            (KeyCode::Char('p'), KeyModifiers::ALT),
229            (KeyCode::Char('P'), KeyModifiers::ALT),
230        ],
231    );
232
233    m
234}
235
236/// Compiled store of key → action mappings, built from defaults + user overrides.
237///
238/// Lookup is O(number of mapped keys) — the total is small (<50 entries) so a
239/// simple linear scan is faster than a nested hash map.
240#[derive(Debug, Clone)]
241pub struct BindingStore {
242    /// Flat list of (key, modifiers) → action for O(n) scan.
243    entries: Vec<(KeyCode, KeyModifiers, Action)>,
244}
245
246impl Default for BindingStore {
247    fn default() -> Self {
248        Self::defaults()
249    }
250}
251
252impl BindingStore {
253    /// Build from a user-provided overlay on top of the built-in defaults.
254    ///
255    /// `overlay` is a `HashMap<action_name, Vec<key_spec_string>>` — exactly
256    /// the shape of `KeyBindingConfig::bindings` and
257    /// `UserPreferences::keybindings`.
258    pub fn new(overlay: HashMap<String, Vec<String>>) -> Self {
259        let mut merged: HashMap<Action, Vec<(KeyCode, KeyModifiers)>> = default_bindings();
260
261        for (action_name, key_specs) in overlay {
262            let Some(action) = Action::from_name(&action_name) else {
263                tracing::debug!(%action_name, "unknown action in keybinding overlay, skipping");
264                continue;
265            };
266
267            let parsed: Vec<(KeyCode, KeyModifiers)> = key_specs
268                .iter()
269                .filter_map(|s| parse_key_binding(s))
270                .collect();
271
272            if parsed.is_empty() {
273                // Empty list → unbind (remove defaults).
274                merged.remove(&action);
275            } else {
276                merged.insert(action, parsed);
277            }
278        }
279
280        let mut entries = Vec::new();
281        for (action, keys) in &merged {
282            for &(code, mods) in keys {
283                entries.push((code, mods, *action));
284            }
285        }
286
287        Self { entries }
288    }
289
290    /// Build with only the default bindings.
291    pub fn defaults() -> Self {
292        Self::new(HashMap::new())
293    }
294
295    /// Look up the action bound to a given key event.
296    ///
297    /// Returns `None` when the key has no binding (fall through to hardcoded
298    /// dispatch).
299    pub fn resolve(&self, key: &KeyEvent) -> Option<Action> {
300        let mut best: Option<(usize, Action)> = None;
301
302        // Iterate entries. For `Char` codes we also try a case-insensitive
303        // match to handle terminal ambiguity (e.g. Ctrl+C vs Ctrl+Shift+C).
304        for (i, &(code, mods, action)) in self.entries.iter().enumerate() {
305            let code_match = match (code, key.code) {
306                (KeyCode::Char(bc), KeyCode::Char(kc)) if bc.eq_ignore_ascii_case(&kc) => true,
307                _ => code == key.code,
308            };
309
310            if !code_match {
311                continue;
312            }
313
314            // All declared modifiers must be present.
315            if !key.modifiers.contains(mods) {
316                continue;
317            }
318
319            // For Char codes, SHIFT is already reflected in character case,
320            // so allow it as an "extra" modifier without penalty.
321            let char_shift_grace = if let KeyCode::Char(_) = key.code {
322                KeyModifiers::SHIFT
323            } else {
324                KeyModifiers::empty()
325            };
326            let extra = key.modifiers.difference(mods);
327            if extra.intersection(!char_shift_grace) != KeyModifiers::empty() {
328                continue;
329            }
330
331            // Prefer earlier entries (user overrides come first, or we use
332            // insertion order and give priority to the first binding).
333            best = match best {
334                None => Some((i, action)),
335                Some((bi, _)) if i < bi => Some((i, action)),
336                Some(other) => Some(other),
337            };
338        }
339
340        best.map(|(_, action)| action)
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use ratatui::crossterm::event::KeyEvent;
348
349    #[test]
350    fn test_parse_key_binding_simple() {
351        let (code, mods) = parse_key_binding("ctrl+c").unwrap();
352        assert_eq!(code, KeyCode::Char('c'));
353        assert!(mods.contains(KeyModifiers::CONTROL));
354        assert!(!mods.contains(KeyModifiers::SHIFT));
355    }
356
357    #[test]
358    fn test_parse_key_binding_modifier_combos() {
359        let (code, mods) = parse_key_binding("ctrl+shift+enter").unwrap();
360        assert_eq!(code, KeyCode::Enter);
361        assert!(mods.contains(KeyModifiers::CONTROL));
362        assert!(mods.contains(KeyModifiers::SHIFT));
363    }
364
365    #[test]
366    fn test_parse_key_binding_func_keys() {
367        let (code, _) = parse_key_binding("f5").unwrap();
368        assert_eq!(code, KeyCode::F(5));
369    }
370
371    #[test]
372    fn test_parse_key_binding_special() {
373        let (code, _) = parse_key_binding("pageup").unwrap();
374        assert_eq!(code, KeyCode::PageUp);
375        let (code, _) = parse_key_binding("backtab").unwrap();
376        assert_eq!(code, KeyCode::BackTab);
377    }
378
379    #[test]
380    fn test_default_bindings_resolve() {
381        let store = BindingStore::defaults();
382
383        // Ctrl+C → Interrupt
384        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
385        assert_eq!(store.resolve(&key), Some(Action::Interrupt));
386
387        // PageUp → ScrollPageUp
388        let key = KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty());
389        assert_eq!(store.resolve(&key), Some(Action::ScrollPageUp));
390
391        // Alt+Up → EditQueue
392        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT);
393        assert_eq!(store.resolve(&key), Some(Action::EditQueue));
394
395        // Unbound → None
396        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
397        assert_eq!(store.resolve(&key), None);
398    }
399
400    #[test]
401    fn test_default_bindings_case_insensitive() {
402        let store = BindingStore::defaults();
403
404        // Ctrl+C (uppercase C) → Interrupt
405        let key = KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL);
406        assert_eq!(store.resolve(&key), Some(Action::Interrupt));
407    }
408
409    #[test]
410    fn test_user_overlay_overrides_default() {
411        let mut overlay = HashMap::new();
412        overlay.insert("interrupt".to_string(), vec!["ctrl+x".to_string()]);
413        let store = BindingStore::new(overlay);
414
415        // Old binding no longer works
416        let key_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
417        assert_eq!(store.resolve(&key_c), None);
418
419        // New binding works
420        let key_x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
421        assert_eq!(store.resolve(&key_x), Some(Action::Interrupt));
422    }
423
424    #[test]
425    fn test_user_overlay_unbind() {
426        let mut overlay = HashMap::new();
427        overlay.insert("interrupt".to_string(), Vec::new());
428        let store = BindingStore::new(overlay);
429
430        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
431        assert_eq!(store.resolve(&key), None);
432    }
433
434    #[test]
435    fn test_parse_invalid_key() {
436        assert!(parse_key_binding("").is_none());
437        assert!(parse_key_binding("invalid_key_name").is_none());
438        assert!(parse_key_binding("ctrl+invalid").is_none());
439        assert!(parse_key_binding("+ctrl+c").is_none());
440    }
441
442    #[test]
443    fn test_action_name_roundtrip() {
444        for action in Action::all() {
445            let name = action.name();
446            let parsed = Action::from_name(name);
447            assert_eq!(parsed, Some(*action));
448        }
449    }
450
451    #[test]
452    fn test_action_from_name_unknown() {
453        assert_eq!(Action::from_name("nonexistent"), None);
454    }
455}