Skip to main content

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