Skip to main content

imp_tui/
keybindings.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2
3/// High-level action triggered by a key binding.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum Action {
6    // Editor actions
7    Submit,
8    NewLine,
9    InsertChar(char),
10    Backspace,
11    Delete,
12    CursorLeft,
13    CursorRight,
14    CursorUp,
15    CursorDown,
16    CursorHome,
17    CursorEnd,
18    WordLeft,
19    WordRight,
20    DeleteWordBack,
21    DeleteToStart,
22    DeleteToEnd,
23    SelectAll,
24    // Navigation
25    ScrollUp,
26    ScrollDown,
27    PageUp,
28    PageDown,
29    // Agent
30    Cancel,
31    FollowUp,
32    // Mode switching
33    SelectModel,
34    CycleModelForward,
35    CycleModelBackward,
36    CycleThinking,
37    Peek,
38    SidebarToggle,
39    SessionTree,
40    Reload,
41    Quit,
42    // Overlays
43    OpenFileFinder,
44    OpenCommandPalette,
45    // Overlay navigation
46    OverlayUp,
47    OverlayDown,
48    OverlaySelect,
49    OverlayDismiss,
50    OverlayFilter(char),
51    OverlayBackspace,
52    // Tool call navigation
53    ToolFocusNext,
54    ToolFocusPrev,
55    /// Open the file referenced by the selected read tool call.
56    OpenSelectedReadFile,
57    /// Toggle the focused tool call's expansion (or all if no focus).
58    ToolToggle,
59}
60
61/// Resolve a key event to an action in normal mode.
62pub fn resolve_normal(key: KeyEvent) -> Option<Action> {
63    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
64    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
65    let alt = key.modifiers.contains(KeyModifiers::ALT);
66
67    match key.code {
68        // Submit / newline
69        KeyCode::Enter if alt => Some(Action::FollowUp),
70        KeyCode::Enter if shift => Some(Action::NewLine),
71        KeyCode::Enter => Some(Action::Submit),
72        KeyCode::Char('j') if ctrl => Some(Action::NewLine),
73
74        // Cancel / quit
75        KeyCode::Char('c') if ctrl => Some(Action::Cancel),
76        KeyCode::Esc => Some(Action::Cancel),
77
78        // Model / thinking
79        KeyCode::Char('l') if ctrl => Some(Action::SelectModel),
80        KeyCode::Char('p') if ctrl && shift => Some(Action::CycleModelBackward),
81        KeyCode::Char('p') if ctrl => Some(Action::CycleModelForward),
82        KeyCode::Char('o') if ctrl => Some(Action::OpenSelectedReadFile),
83        KeyCode::BackTab => Some(Action::CycleThinking),
84
85        // Sidebar / tool navigation
86        KeyCode::Tab => Some(Action::SidebarToggle),
87
88        // Cursor movement
89        KeyCode::Left if ctrl => Some(Action::WordLeft),
90        KeyCode::Right if ctrl => Some(Action::WordRight),
91        KeyCode::Left => Some(Action::CursorLeft),
92        KeyCode::Right => Some(Action::CursorRight),
93        KeyCode::Up if ctrl => Some(Action::ToolFocusPrev),
94        KeyCode::Down if ctrl => Some(Action::ToolFocusNext),
95        KeyCode::Up => Some(Action::CursorUp),
96        KeyCode::Down => Some(Action::CursorDown),
97        KeyCode::Home => Some(Action::CursorHome),
98        KeyCode::End => Some(Action::CursorEnd),
99
100        // Editing shortcuts
101        KeyCode::Char('a') if ctrl => Some(Action::CursorHome),
102        KeyCode::Char('e') if ctrl => Some(Action::CursorEnd),
103        KeyCode::Char('w') if ctrl => Some(Action::DeleteWordBack),
104        KeyCode::Char('u') if ctrl => Some(Action::DeleteToStart),
105        KeyCode::Char('k') if ctrl => Some(Action::DeleteToEnd),
106
107        // Delete
108        KeyCode::Backspace => Some(Action::Backspace),
109        KeyCode::Delete => Some(Action::Delete),
110
111        // Scroll
112        KeyCode::PageUp => Some(Action::PageUp),
113        KeyCode::PageDown => Some(Action::PageDown),
114        KeyCode::Char('b') if ctrl => Some(Action::PageUp),
115        KeyCode::Char('f') if ctrl => Some(Action::PageDown),
116
117        // Character input
118        KeyCode::Char(c) => Some(Action::InsertChar(c)),
119
120        _ => None,
121    }
122}
123
124/// Resolve a key event to an action in overlay mode (model selector, command palette, file finder).
125pub fn resolve_overlay(key: KeyEvent) -> Option<Action> {
126    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
127
128    match key.code {
129        KeyCode::Up => Some(Action::OverlayUp),
130        KeyCode::Down => Some(Action::OverlayDown),
131        KeyCode::Tab => Some(Action::OverlayDown),
132        KeyCode::BackTab => Some(Action::OverlayUp),
133        KeyCode::Char('n') if ctrl => Some(Action::OverlayDown),
134        KeyCode::Char('p') if ctrl => Some(Action::OverlayUp),
135        KeyCode::Enter => Some(Action::OverlaySelect),
136        KeyCode::Esc => Some(Action::OverlayDismiss),
137        KeyCode::Backspace => Some(Action::OverlayBackspace),
138        KeyCode::Char(c) => Some(Action::OverlayFilter(c)),
139        _ => None,
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn ctrl_p_cycles_model_forward() {
149        assert_eq!(
150            resolve_normal(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)),
151            Some(Action::CycleModelForward)
152        );
153    }
154
155    #[test]
156    fn ctrl_shift_p_cycles_model_backward() {
157        assert_eq!(
158            resolve_normal(KeyEvent::new(
159                KeyCode::Char('p'),
160                KeyModifiers::CONTROL | KeyModifiers::SHIFT
161            )),
162            Some(Action::CycleModelBackward)
163        );
164    }
165
166    #[test]
167    fn tab_toggles_sidebar() {
168        assert_eq!(
169            resolve_normal(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())),
170            Some(Action::SidebarToggle)
171        );
172    }
173
174    #[test]
175    fn ctrl_p_no_longer_toggles_sidebar() {
176        assert_ne!(
177            resolve_normal(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)),
178            Some(Action::SidebarToggle)
179        );
180    }
181}