agent_core/tui/keys/
handler.rs

1//! Key handler trait and default implementation.
2
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4
5use super::bindings::KeyBindings;
6use super::exit::ExitState;
7use super::types::{AppKeyAction, AppKeyResult, KeyContext};
8
9/// Trait for customizing key handling at the App level.
10///
11/// Implement this to customize how keys are processed BEFORE
12/// they reach widgets or default text input handling.
13///
14/// # Key Flow
15///
16/// ```text
17/// Key Press
18///     |
19/// KeyHandler.handle_key(key, context)  <- context.widget_blocking tells if modal is open
20///     |
21/// If NotHandled -> Widget dispatch (modals like QuestionPanel get the key)
22///     |
23/// If still unhandled -> Default text input handling
24/// ```
25pub trait KeyHandler: Send + 'static {
26    /// Handle a key event.
27    ///
28    /// Called for every key press. Return:
29    /// - `NotHandled` to pass to widgets and default handling
30    /// - `Handled` to consume the key
31    /// - `Action(...)` to execute an app action
32    ///
33    /// # Arguments
34    /// * `key` - The key event
35    /// * `context` - Current app context (input state, processing state, etc.)
36    fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult;
37
38    /// Get a status hint to display in the status bar.
39    ///
40    /// This allows the handler to provide context-sensitive hints,
41    /// such as "Press again to exit" when in exit confirmation mode.
42    fn status_hint(&self) -> Option<String> {
43        None
44    }
45}
46
47/// Default key handler with configurable bindings.
48///
49/// This implementation uses [`KeyBindings`] to determine what actions
50/// to take for each key press. It handles the standard key processing
51/// flow while allowing customization of all bindings.
52///
53/// The handler manages exit confirmation state internally, so agents
54/// get the two-key exit flow (e.g., press Ctrl+D twice) without needing
55/// to track any state in the App.
56pub struct DefaultKeyHandler {
57    bindings: KeyBindings,
58    exit_state: ExitState,
59}
60
61impl DefaultKeyHandler {
62    /// Create a new handler with the given bindings.
63    pub fn new(bindings: KeyBindings) -> Self {
64        Self {
65            bindings,
66            exit_state: ExitState::default(),
67        }
68    }
69
70    /// Check if the given key matches the exit mode binding.
71    fn is_exit_key(&self, key: &KeyEvent) -> bool {
72        KeyBindings::matches_any(&self.bindings.enter_exit_mode, key)
73    }
74}
75
76impl Default for DefaultKeyHandler {
77    fn default() -> Self {
78        Self::new(KeyBindings::default())
79    }
80}
81
82impl KeyHandler for DefaultKeyHandler {
83    fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult {
84        // Check if exit confirmation has expired
85        if self.exit_state.is_expired() {
86            self.exit_state.reset();
87        }
88
89        // When a modal widget is blocking, let it handle most keys.
90        // Only intercept "force quit" type bindings.
91        if context.widget_blocking {
92            // Still allow force-quit (e.g., Ctrl+Q) even in modals
93            if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
94                return AppKeyResult::Action(AppKeyAction::Quit);
95            }
96            // Let the modal widget handle everything else
97            return AppKeyResult::NotHandled;
98        }
99
100        // When processing (spinner active), only allow interrupt and exit
101        if context.is_processing {
102            if KeyBindings::matches_any(&self.bindings.interrupt, &key) {
103                return AppKeyResult::Action(AppKeyAction::Interrupt);
104            }
105            if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
106                return AppKeyResult::Action(AppKeyAction::Quit);
107            }
108            // Handle exit mode: exit key to enter or confirm exit
109            if self.is_exit_key(&key) {
110                if self.exit_state.is_awaiting() {
111                    self.exit_state.reset();
112                    return AppKeyResult::Action(AppKeyAction::RequestExit);
113                } else if context.input_empty {
114                    self.exit_state = ExitState::awaiting_confirmation(
115                        self.bindings.exit_timeout_secs,
116                    );
117                    return AppKeyResult::Handled;
118                }
119            }
120            // Ignore all other keys during processing
121            return AppKeyResult::Handled;
122        }
123
124        // Check for exit mode confirmation (handler manages this internally)
125        if self.exit_state.is_awaiting() {
126            if self.is_exit_key(&key) {
127                self.exit_state.reset();
128                return AppKeyResult::Action(AppKeyAction::RequestExit);
129            }
130            // Any other key cancels exit mode
131            self.exit_state.reset();
132            // Fall through to normal handling
133        }
134
135        // Application-level bindings
136        if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
137            return AppKeyResult::Action(AppKeyAction::Quit);
138        }
139        if KeyBindings::matches_any(&self.bindings.quit, &key) && context.input_empty {
140            return AppKeyResult::Action(AppKeyAction::Quit);
141        }
142        if self.is_exit_key(&key) {
143            if context.input_empty {
144                // Enter exit confirmation mode
145                self.exit_state = ExitState::awaiting_confirmation(
146                    self.bindings.exit_timeout_secs,
147                );
148                return AppKeyResult::Handled;
149            }
150            // When not empty, Ctrl+D is delete char at cursor
151            return AppKeyResult::Action(AppKeyAction::DeleteCharAt);
152        }
153        if KeyBindings::matches_any(&self.bindings.submit, &key) {
154            return AppKeyResult::Action(AppKeyAction::Submit);
155        }
156        if KeyBindings::matches_any(&self.bindings.interrupt, &key) {
157            return AppKeyResult::Action(AppKeyAction::Interrupt);
158        }
159
160        // Navigation bindings
161        if KeyBindings::matches_any(&self.bindings.move_up, &key) {
162            return AppKeyResult::Action(AppKeyAction::MoveUp);
163        }
164        if KeyBindings::matches_any(&self.bindings.move_down, &key) {
165            return AppKeyResult::Action(AppKeyAction::MoveDown);
166        }
167        if KeyBindings::matches_any(&self.bindings.move_left, &key) {
168            return AppKeyResult::Action(AppKeyAction::MoveLeft);
169        }
170        if KeyBindings::matches_any(&self.bindings.move_right, &key) {
171            return AppKeyResult::Action(AppKeyAction::MoveRight);
172        }
173        if KeyBindings::matches_any(&self.bindings.move_line_start, &key) {
174            return AppKeyResult::Action(AppKeyAction::MoveLineStart);
175        }
176        if KeyBindings::matches_any(&self.bindings.move_line_end, &key) {
177            return AppKeyResult::Action(AppKeyAction::MoveLineEnd);
178        }
179
180        // Editing bindings
181        if KeyBindings::matches_any(&self.bindings.delete_char_before, &key) {
182            return AppKeyResult::Action(AppKeyAction::DeleteCharBefore);
183        }
184        if KeyBindings::matches_any(&self.bindings.delete_char_at, &key) {
185            return AppKeyResult::Action(AppKeyAction::DeleteCharAt);
186        }
187        if KeyBindings::matches_any(&self.bindings.kill_line, &key) {
188            return AppKeyResult::Action(AppKeyAction::KillLine);
189        }
190        if KeyBindings::matches_any(&self.bindings.insert_newline, &key) {
191            return AppKeyResult::Action(AppKeyAction::InsertNewline);
192        }
193
194        // Character input - return InsertChar for regular characters
195        if let KeyCode::Char(c) = key.code {
196            if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
197                return AppKeyResult::Action(AppKeyAction::InsertChar(c));
198            }
199        }
200
201        // Unhandled - let widgets or default handling take over
202        AppKeyResult::NotHandled
203    }
204
205    fn status_hint(&self) -> Option<String> {
206        if self.exit_state.is_awaiting() {
207            Some("Press again to exit".to_string())
208        } else {
209            None
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_default_handler_force_quit_in_modal() {
220        let mut handler = DefaultKeyHandler::default();
221        let context = KeyContext {
222            input_empty: true,
223            is_processing: false,
224            widget_blocking: true, // Modal is open
225        };
226
227        // Ctrl+Q should still work when modal is blocking
228        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
229        let result = handler.handle_key(key, &context);
230        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Quit));
231
232        // Regular Esc should not be handled (let modal handle it)
233        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
234        let result = handler.handle_key(esc, &context);
235        assert_eq!(result, AppKeyResult::NotHandled);
236    }
237
238    #[test]
239    fn test_emacs_handler_processing_mode() {
240        // Use emacs bindings which have interrupt on Esc
241        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs());
242        let context = KeyContext {
243            input_empty: true,
244            is_processing: true, // Spinner is active
245            widget_blocking: false,
246        };
247
248        // Esc should interrupt (emacs has interrupt binding)
249        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
250        let result = handler.handle_key(esc, &context);
251        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Interrupt));
252
253        // Regular keys should be consumed (Handled)
254        let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
255        let result = handler.handle_key(a, &context);
256        assert_eq!(result, AppKeyResult::Handled);
257    }
258
259    #[test]
260    fn test_emacs_handler_exit_mode() {
261        // Use emacs bindings which have Ctrl+D for exit mode
262        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs());
263        let context = KeyContext {
264            input_empty: true,
265            is_processing: false,
266            widget_blocking: false,
267        };
268
269        // First Ctrl+D enters exit mode, returns Handled
270        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
271        let result = handler.handle_key(ctrl_d, &context);
272        assert_eq!(result, AppKeyResult::Handled);
273
274        // Handler should now show status hint
275        assert!(handler.status_hint().is_some());
276
277        // Second Ctrl+D should request exit
278        let result = handler.handle_key(ctrl_d, &context);
279        assert_eq!(result, AppKeyResult::Action(AppKeyAction::RequestExit));
280
281        // Status hint should be cleared
282        assert!(handler.status_hint().is_none());
283    }
284
285    #[test]
286    fn test_bare_minimum_handler_quit() {
287        let mut handler = DefaultKeyHandler::default(); // Uses bare_minimum
288        let context = KeyContext {
289            input_empty: true,
290            is_processing: false,
291            widget_blocking: false,
292        };
293
294        // Esc should quit when input is empty (bare_minimum has no exit mode)
295        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
296        let result = handler.handle_key(esc, &context);
297        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Quit));
298    }
299
300    #[test]
301    fn test_default_handler_char_input() {
302        let mut handler = DefaultKeyHandler::default();
303        let context = KeyContext {
304            input_empty: true,
305            is_processing: false,
306            widget_blocking: false,
307        };
308
309        // Regular character should be InsertChar
310        let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
311        let result = handler.handle_key(a, &context);
312        assert_eq!(result, AppKeyResult::Action(AppKeyAction::InsertChar('a')));
313
314        // Shift+character should also be InsertChar
315        let shift_a = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
316        let result = handler.handle_key(shift_a, &context);
317        assert_eq!(result, AppKeyResult::Action(AppKeyAction::InsertChar('A')));
318    }
319
320    #[test]
321    fn test_exit_mode_cancelled_by_other_key() {
322        // Use emacs bindings which have Ctrl+D for exit mode
323        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs());
324        let context = KeyContext {
325            input_empty: true,
326            is_processing: false,
327            widget_blocking: false,
328        };
329
330        // First Ctrl+D enters exit mode
331        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
332        let result = handler.handle_key(ctrl_d, &context);
333        assert_eq!(result, AppKeyResult::Handled);
334        assert!(handler.status_hint().is_some());
335
336        // Pressing another key cancels exit mode
337        let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
338        let result = handler.handle_key(a, &context);
339        assert_eq!(result, AppKeyResult::Action(AppKeyAction::InsertChar('a')));
340
341        // Status hint should be cleared
342        assert!(handler.status_hint().is_none());
343    }
344}