Skip to main content

agent_air_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, KeyCombo, KeyContext};
8
9/// Closure type for key handler pre-hooks.
10pub type KeyHookFn = Box<dyn Fn(&KeyEvent, &KeyContext) -> Option<AppKeyResult> + Send>;
11
12/// Trait for customizing key handling at the App level.
13///
14/// Implement this to customize how keys are processed BEFORE
15/// they reach widgets or default text input handling.
16///
17/// # Key Flow
18///
19/// ```text
20/// Key Press
21///     |
22/// KeyHandler.handle_key(key, context)  <- context.widget_blocking tells if modal is open
23///     |
24/// If NotHandled -> Widget dispatch (modals like QuestionPanel get the key)
25///     |
26/// If still unhandled -> Default text input handling
27/// ```
28pub trait KeyHandler: Send + 'static {
29    /// Handle a key event.
30    ///
31    /// Called for every key press. Return:
32    /// - `NotHandled` to pass to widgets and default handling
33    /// - `Handled` to consume the key
34    /// - `Action(...)` to execute an app action
35    ///
36    /// # Arguments
37    /// * `key` - The key event
38    /// * `context` - Current app context (input state, processing state, etc.)
39    fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult;
40
41    /// Get a status hint to display in the status bar.
42    ///
43    /// This allows the handler to provide context-sensitive hints,
44    /// such as "Press again to exit" when in exit confirmation mode.
45    fn status_hint(&self) -> Option<String> {
46        None
47    }
48
49    /// Get a reference to the key bindings.
50    ///
51    /// This is used by widgets to check navigation keys against configured bindings.
52    /// The default implementation returns `minimal` bindings.
53    fn bindings(&self) -> &KeyBindings;
54}
55
56/// Default key handler with configurable bindings.
57///
58/// This implementation uses [`KeyBindings`] to determine what actions
59/// to take for each key press. It handles the standard key processing
60/// flow while allowing customization of all bindings.
61///
62/// The handler manages exit confirmation state internally, so agents
63/// get the two-key exit flow (e.g., press Ctrl+D twice) without needing
64/// to track any state in the App.
65///
66/// # Custom Bindings
67///
68/// In addition to the standard bindings, you can add custom key bindings
69/// that trigger custom actions:
70///
71/// ```ignore
72/// let handler = DefaultKeyHandler::new(KeyBindings::emacs())
73///     .with_custom_binding(KeyCombo::ctrl('t'), || {
74///         AppKeyAction::custom(MyCustomAction::ToggleSomething)
75///     });
76/// ```
77pub struct DefaultKeyHandler {
78    bindings: KeyBindings,
79    exit_state: ExitState,
80    custom_bindings: Vec<(KeyCombo, Box<dyn Fn() -> AppKeyAction + Send + Sync>)>,
81}
82
83impl DefaultKeyHandler {
84    /// Create a new handler with the given bindings.
85    pub fn new(bindings: KeyBindings) -> Self {
86        Self {
87            bindings,
88            exit_state: ExitState::default(),
89            custom_bindings: Vec::new(),
90        }
91    }
92
93    /// Get a reference to the key bindings.
94    pub fn bindings(&self) -> &KeyBindings {
95        &self.bindings
96    }
97
98    /// Add a custom key binding that triggers a custom action.
99    ///
100    /// Custom bindings are checked before standard bindings, allowing
101    /// you to override default behavior or add new key combinations.
102    ///
103    /// # Arguments
104    ///
105    /// * `combo` - The key combination to bind
106    /// * `action_fn` - A function that returns the action to execute
107    ///
108    /// # Example
109    ///
110    /// ```ignore
111    /// let handler = DefaultKeyHandler::new(KeyBindings::emacs())
112    ///     .with_custom_binding(KeyCombo::ctrl('t'), || {
113    ///         AppKeyAction::custom(MyCustomAction::ToggleSomething)
114    ///     })
115    ///     .with_custom_binding(KeyCombo::alt('h'), || {
116    ///         AppKeyAction::custom(MyCustomAction::ShowHelp)
117    ///     });
118    /// ```
119    pub fn with_custom_binding<F>(mut self, combo: KeyCombo, action_fn: F) -> Self
120    where
121        F: Fn() -> AppKeyAction + Send + Sync + 'static,
122    {
123        self.custom_bindings.push((combo, Box::new(action_fn)));
124        self
125    }
126
127    /// Check if the given key matches the exit mode binding.
128    fn is_exit_key(&self, key: &KeyEvent) -> bool {
129        KeyBindings::matches_any(&self.bindings.enter_exit_mode, key)
130    }
131
132    /// Check if the given key matches any custom binding.
133    fn check_custom_binding(&self, key: &KeyEvent) -> Option<AppKeyAction> {
134        for (combo, action_fn) in &self.custom_bindings {
135            if combo.matches(key) {
136                return Some(action_fn());
137            }
138        }
139        None
140    }
141}
142
143impl Default for DefaultKeyHandler {
144    fn default() -> Self {
145        Self::new(KeyBindings::default())
146    }
147}
148
149impl KeyHandler for DefaultKeyHandler {
150    fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult {
151        // Check if exit confirmation has expired
152        if self.exit_state.is_expired() {
153            self.exit_state.reset();
154        }
155
156        // When a modal widget is blocking, let it handle most keys.
157        // Only intercept "force quit" type bindings.
158        if context.widget_blocking {
159            // Still allow force-quit (e.g., Ctrl+Q) even in modals
160            if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
161                return AppKeyResult::Action(AppKeyAction::Quit);
162            }
163            // Let the modal widget handle everything else
164            return AppKeyResult::NotHandled;
165        }
166
167        // When processing (spinner active), only allow interrupt and exit
168        if context.is_processing {
169            if KeyBindings::matches_any(&self.bindings.interrupt, &key) {
170                return AppKeyResult::Action(AppKeyAction::Interrupt);
171            }
172            if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
173                return AppKeyResult::Action(AppKeyAction::Quit);
174            }
175            // Handle exit mode: exit key to enter or confirm exit
176            if self.is_exit_key(&key) {
177                if self.exit_state.is_awaiting() {
178                    self.exit_state.reset();
179                    return AppKeyResult::Action(AppKeyAction::RequestExit);
180                } else if context.input_empty {
181                    self.exit_state =
182                        ExitState::awaiting_confirmation(self.bindings.exit_timeout_secs);
183                    return AppKeyResult::Handled;
184                }
185            }
186            // Ignore all other keys during processing
187            return AppKeyResult::Handled;
188        }
189
190        // Check for exit mode confirmation (handler manages this internally)
191        if self.exit_state.is_awaiting() {
192            if self.is_exit_key(&key) {
193                self.exit_state.reset();
194                return AppKeyResult::Action(AppKeyAction::RequestExit);
195            }
196            // Any other key cancels exit mode
197            self.exit_state.reset();
198            // Fall through to normal handling
199        }
200
201        // Check custom bindings first (allows overriding standard bindings)
202        if let Some(action) = self.check_custom_binding(&key) {
203            return AppKeyResult::Action(action);
204        }
205
206        // Application-level bindings
207        if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
208            return AppKeyResult::Action(AppKeyAction::Quit);
209        }
210        if KeyBindings::matches_any(&self.bindings.quit, &key) && context.input_empty {
211            return AppKeyResult::Action(AppKeyAction::Quit);
212        }
213        if self.is_exit_key(&key) {
214            if context.input_empty {
215                // Enter exit confirmation mode
216                self.exit_state = ExitState::awaiting_confirmation(self.bindings.exit_timeout_secs);
217                return AppKeyResult::Handled;
218            }
219            // When not empty, Ctrl+D is delete char at cursor
220            return AppKeyResult::Action(AppKeyAction::DeleteCharAt);
221        }
222        if KeyBindings::matches_any(&self.bindings.submit, &key) {
223            return AppKeyResult::Action(AppKeyAction::Submit);
224        }
225        if KeyBindings::matches_any(&self.bindings.interrupt, &key) {
226            return AppKeyResult::Action(AppKeyAction::Interrupt);
227        }
228
229        // Navigation bindings
230        if KeyBindings::matches_any(&self.bindings.move_up, &key) {
231            return AppKeyResult::Action(AppKeyAction::MoveUp);
232        }
233        if KeyBindings::matches_any(&self.bindings.move_down, &key) {
234            return AppKeyResult::Action(AppKeyAction::MoveDown);
235        }
236        if KeyBindings::matches_any(&self.bindings.move_left, &key) {
237            return AppKeyResult::Action(AppKeyAction::MoveLeft);
238        }
239        if KeyBindings::matches_any(&self.bindings.move_right, &key) {
240            return AppKeyResult::Action(AppKeyAction::MoveRight);
241        }
242        if KeyBindings::matches_any(&self.bindings.move_line_start, &key) {
243            return AppKeyResult::Action(AppKeyAction::MoveLineStart);
244        }
245        if KeyBindings::matches_any(&self.bindings.move_line_end, &key) {
246            return AppKeyResult::Action(AppKeyAction::MoveLineEnd);
247        }
248
249        // Editing bindings
250        if KeyBindings::matches_any(&self.bindings.delete_char_before, &key) {
251            return AppKeyResult::Action(AppKeyAction::DeleteCharBefore);
252        }
253        if KeyBindings::matches_any(&self.bindings.delete_char_at, &key) {
254            return AppKeyResult::Action(AppKeyAction::DeleteCharAt);
255        }
256        if KeyBindings::matches_any(&self.bindings.kill_line, &key) {
257            return AppKeyResult::Action(AppKeyAction::KillLine);
258        }
259        if KeyBindings::matches_any(&self.bindings.insert_newline, &key) {
260            return AppKeyResult::Action(AppKeyAction::InsertNewline);
261        }
262
263        // Character input - return InsertChar for regular characters
264        if let KeyCode::Char(c) = key.code
265            && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT)
266        {
267            return AppKeyResult::Action(AppKeyAction::InsertChar(c));
268        }
269
270        // Unhandled - let widgets or default handling take over
271        AppKeyResult::NotHandled
272    }
273
274    fn status_hint(&self) -> Option<String> {
275        if self.exit_state.is_awaiting() {
276            Some("Press again to exit".to_string())
277        } else {
278            None
279        }
280    }
281
282    fn bindings(&self) -> &KeyBindings {
283        &self.bindings
284    }
285}
286
287/// A composable key handler wrapper with pre-processing hooks.
288///
289/// `ComposedKeyHandler` wraps an inner handler and allows adding hooks
290/// that run before the inner handler processes keys. This enables:
291///
292/// - Intercepting specific keys before they reach the inner handler
293/// - Adding logging or debugging behavior
294/// - Implementing layered key handling (e.g., modal modes on top of base handler)
295///
296/// # Example
297///
298/// ```ignore
299/// let base_handler = DefaultKeyHandler::new(KeyBindings::emacs());
300/// let handler = ComposedKeyHandler::new(base_handler)
301///     .with_pre_hook(|key, _ctx| {
302///         // Intercept F1 for help
303///         if key.code == KeyCode::F(1) {
304///             return Some(AppKeyResult::Action(AppKeyAction::custom(ShowHelp)));
305///         }
306///         None // Let inner handler process
307///     });
308/// ```
309pub struct ComposedKeyHandler<H: KeyHandler> {
310    inner: H,
311    pre_hooks: Vec<KeyHookFn>,
312}
313
314impl<H: KeyHandler> ComposedKeyHandler<H> {
315    /// Create a new composed handler wrapping the given inner handler.
316    pub fn new(inner: H) -> Self {
317        Self {
318            inner,
319            pre_hooks: Vec::new(),
320        }
321    }
322
323    /// Add a hook that runs before the inner handler.
324    ///
325    /// If the hook returns `Some(result)`, that result is used and the
326    /// inner handler is skipped. If the hook returns `None`, processing
327    /// continues to the next hook or the inner handler.
328    ///
329    /// Hooks are called in the order they were added.
330    ///
331    /// # Arguments
332    ///
333    /// * `hook` - A function that receives the key event and context,
334    ///   returning `Some(AppKeyResult)` to handle the key or `None` to pass through
335    ///
336    /// # Example
337    ///
338    /// ```ignore
339    /// let handler = ComposedKeyHandler::new(DefaultKeyHandler::default())
340    ///     .with_pre_hook(|key, ctx| {
341    ///         // Log all key presses
342    ///         eprintln!("Key: {:?}", key);
343    ///         None // Don't consume, let inner handler process
344    ///     })
345    ///     .with_pre_hook(|key, _ctx| {
346    ///         // Intercept Ctrl+H for custom help
347    ///         if key.code == KeyCode::Char('h') && key.modifiers == KeyModifiers::CONTROL {
348    ///             return Some(AppKeyResult::Action(AppKeyAction::custom(MyHelp)));
349    ///         }
350    ///         None
351    ///     });
352    /// ```
353    pub fn with_pre_hook<F>(mut self, hook: F) -> Self
354    where
355        F: Fn(&KeyEvent, &KeyContext) -> Option<AppKeyResult> + Send + 'static,
356    {
357        self.pre_hooks.push(Box::new(hook));
358        self
359    }
360
361    /// Get a reference to the inner handler.
362    pub fn inner(&self) -> &H {
363        &self.inner
364    }
365
366    /// Get a mutable reference to the inner handler.
367    pub fn inner_mut(&mut self) -> &mut H {
368        &mut self.inner
369    }
370}
371
372impl<H: KeyHandler> KeyHandler for ComposedKeyHandler<H> {
373    fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult {
374        // Run pre-hooks in order
375        for hook in &self.pre_hooks {
376            if let Some(result) = hook(&key, context) {
377                return result;
378            }
379        }
380
381        // Fall through to inner handler
382        self.inner.handle_key(key, context)
383    }
384
385    fn status_hint(&self) -> Option<String> {
386        self.inner.status_hint()
387    }
388
389    fn bindings(&self) -> &KeyBindings {
390        self.inner.bindings()
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn test_default_handler_force_quit_in_modal() {
400        let mut handler = DefaultKeyHandler::default();
401        let context = KeyContext {
402            input_empty: true,
403            is_processing: false,
404            widget_blocking: true, // Modal is open
405        };
406
407        // Ctrl+Q should still work when modal is blocking
408        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
409        let result = handler.handle_key(key, &context);
410        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Quit));
411
412        // Regular Esc should not be handled (let modal handle it)
413        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
414        let result = handler.handle_key(esc, &context);
415        assert_eq!(result, AppKeyResult::NotHandled);
416    }
417
418    #[test]
419    fn test_emacs_handler_processing_mode() {
420        // Use emacs bindings which have interrupt on Esc
421        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs());
422        let context = KeyContext {
423            input_empty: true,
424            is_processing: true, // Spinner is active
425            widget_blocking: false,
426        };
427
428        // Esc should interrupt (emacs has interrupt binding)
429        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
430        let result = handler.handle_key(esc, &context);
431        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Interrupt));
432
433        // Regular keys should be consumed (Handled)
434        let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
435        let result = handler.handle_key(a, &context);
436        assert_eq!(result, AppKeyResult::Handled);
437    }
438
439    #[test]
440    fn test_emacs_handler_exit_mode() {
441        // Use emacs bindings which have Ctrl+D for exit mode
442        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs());
443        let context = KeyContext {
444            input_empty: true,
445            is_processing: false,
446            widget_blocking: false,
447        };
448
449        // First Ctrl+D enters exit mode, returns Handled
450        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
451        let result = handler.handle_key(ctrl_d, &context);
452        assert_eq!(result, AppKeyResult::Handled);
453
454        // Handler should now show status hint
455        assert!(handler.status_hint().is_some());
456
457        // Second Ctrl+D should request exit
458        let result = handler.handle_key(ctrl_d, &context);
459        assert_eq!(result, AppKeyResult::Action(AppKeyAction::RequestExit));
460
461        // Status hint should be cleared
462        assert!(handler.status_hint().is_none());
463    }
464
465    #[test]
466    fn test_minimal_handler_quit() {
467        let mut handler = DefaultKeyHandler::default(); // Uses minimal
468        let context = KeyContext {
469            input_empty: true,
470            is_processing: false,
471            widget_blocking: false,
472        };
473
474        // Esc should quit when input is empty (minimal has no exit mode)
475        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
476        let result = handler.handle_key(esc, &context);
477        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Quit));
478    }
479
480    #[test]
481    fn test_default_handler_char_input() {
482        let mut handler = DefaultKeyHandler::default();
483        let context = KeyContext {
484            input_empty: true,
485            is_processing: false,
486            widget_blocking: false,
487        };
488
489        // Regular character should be InsertChar
490        let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
491        let result = handler.handle_key(a, &context);
492        assert_eq!(result, AppKeyResult::Action(AppKeyAction::InsertChar('a')));
493
494        // Shift+character should also be InsertChar
495        let shift_a = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
496        let result = handler.handle_key(shift_a, &context);
497        assert_eq!(result, AppKeyResult::Action(AppKeyAction::InsertChar('A')));
498    }
499
500    #[test]
501    fn test_exit_mode_cancelled_by_other_key() {
502        // Use emacs bindings which have Ctrl+D for exit mode
503        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs());
504        let context = KeyContext {
505            input_empty: true,
506            is_processing: false,
507            widget_blocking: false,
508        };
509
510        // First Ctrl+D enters exit mode
511        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
512        let result = handler.handle_key(ctrl_d, &context);
513        assert_eq!(result, AppKeyResult::Handled);
514        assert!(handler.status_hint().is_some());
515
516        // Pressing another key cancels exit mode
517        let a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
518        let result = handler.handle_key(a, &context);
519        assert_eq!(result, AppKeyResult::Action(AppKeyAction::InsertChar('a')));
520
521        // Status hint should be cleared
522        assert!(handler.status_hint().is_none());
523    }
524
525    #[test]
526    fn test_custom_binding_basic() {
527        // Create handler with a custom binding for Ctrl+T
528        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs())
529            .with_custom_binding(KeyCombo::ctrl('t'), || AppKeyAction::custom("toggle"));
530
531        let context = KeyContext {
532            input_empty: true,
533            is_processing: false,
534            widget_blocking: false,
535        };
536
537        // Ctrl+T should trigger the custom action
538        let ctrl_t = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL);
539        let result = handler.handle_key(ctrl_t, &context);
540
541        if let AppKeyResult::Action(AppKeyAction::Custom(any)) = result {
542            assert!(any.downcast_ref::<&str>().is_some());
543        } else {
544            panic!("Expected Custom action, got {:?}", result);
545        }
546    }
547
548    #[test]
549    fn test_custom_binding_overrides_standard() {
550        // Custom binding for Ctrl+P should override the standard move_up
551        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs())
552            .with_custom_binding(KeyCombo::ctrl('p'), || AppKeyAction::custom("custom_up"));
553
554        let context = KeyContext {
555            input_empty: true,
556            is_processing: false,
557            widget_blocking: false,
558        };
559
560        let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
561        let result = handler.handle_key(ctrl_p, &context);
562
563        // Should get custom action, not MoveUp
564        if let AppKeyResult::Action(AppKeyAction::Custom(_)) = result {
565            // Good - custom binding took precedence
566        } else {
567            panic!(
568                "Expected Custom action to override MoveUp, got {:?}",
569                result
570            );
571        }
572    }
573
574    #[test]
575    fn test_composed_handler_basic() {
576        let base = DefaultKeyHandler::new(KeyBindings::minimal());
577        let mut composed = ComposedKeyHandler::new(base);
578
579        let context = KeyContext {
580            input_empty: true,
581            is_processing: false,
582            widget_blocking: false,
583        };
584
585        // Without hooks, should behave like the inner handler
586        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
587        let result = composed.handle_key(esc, &context);
588        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Quit));
589    }
590
591    #[test]
592    fn test_composed_handler_pre_hook_intercepts() {
593        let base = DefaultKeyHandler::new(KeyBindings::minimal());
594        let mut composed = ComposedKeyHandler::new(base).with_pre_hook(|key, _ctx| {
595            // Intercept F1 key
596            if key.code == KeyCode::F(1) {
597                return Some(AppKeyResult::Action(AppKeyAction::custom("help")));
598            }
599            None
600        });
601
602        let context = KeyContext {
603            input_empty: true,
604            is_processing: false,
605            widget_blocking: false,
606        };
607
608        // F1 should be intercepted by the hook
609        let f1 = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
610        let result = composed.handle_key(f1, &context);
611
612        if let AppKeyResult::Action(AppKeyAction::Custom(_)) = result {
613            // Good - hook intercepted
614        } else {
615            panic!("Expected hook to intercept F1, got {:?}", result);
616        }
617
618        // Other keys should pass through to inner handler
619        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
620        let result = composed.handle_key(esc, &context);
621        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Quit));
622    }
623
624    #[test]
625    fn test_composed_handler_multiple_hooks() {
626        let base = DefaultKeyHandler::new(KeyBindings::minimal());
627        let mut composed = ComposedKeyHandler::new(base)
628            .with_pre_hook(|key, _ctx| {
629                // First hook intercepts F1
630                if key.code == KeyCode::F(1) {
631                    return Some(AppKeyResult::Action(AppKeyAction::custom("first")));
632                }
633                None
634            })
635            .with_pre_hook(|key, _ctx| {
636                // Second hook intercepts F2 and also F1 (but won't get F1)
637                if key.code == KeyCode::F(2) {
638                    return Some(AppKeyResult::Action(AppKeyAction::custom("second")));
639                }
640                if key.code == KeyCode::F(1) {
641                    return Some(AppKeyResult::Action(AppKeyAction::custom("should_not_see")));
642                }
643                None
644            });
645
646        let context = KeyContext {
647            input_empty: true,
648            is_processing: false,
649            widget_blocking: false,
650        };
651
652        // F1 should be handled by first hook
653        let f1 = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
654        let result = composed.handle_key(f1, &context);
655        if let AppKeyResult::Action(AppKeyAction::Custom(any)) = result {
656            let s = any.downcast_ref::<&str>().unwrap();
657            assert_eq!(*s, "first");
658        } else {
659            panic!("Expected first hook to handle F1");
660        }
661
662        // F2 should be handled by second hook
663        let f2 = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
664        let result = composed.handle_key(f2, &context);
665        if let AppKeyResult::Action(AppKeyAction::Custom(any)) = result {
666            let s = any.downcast_ref::<&str>().unwrap();
667            assert_eq!(*s, "second");
668        } else {
669            panic!("Expected second hook to handle F2");
670        }
671    }
672
673    #[test]
674    fn test_composed_handler_status_hint() {
675        // Inner handler's status_hint should be accessible
676        let mut base = DefaultKeyHandler::new(KeyBindings::emacs());
677
678        // Put base handler into exit mode
679        let context = KeyContext {
680            input_empty: true,
681            is_processing: false,
682            widget_blocking: false,
683        };
684        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
685        base.handle_key(ctrl_d, &context);
686
687        // Now wrap it
688        let composed = ComposedKeyHandler::new(base);
689
690        // Status hint should come from inner handler
691        assert!(composed.status_hint().is_some());
692        assert!(composed.status_hint().unwrap().contains("exit"));
693    }
694
695    #[test]
696    fn test_composed_handler_inner_access() {
697        let base = DefaultKeyHandler::new(KeyBindings::emacs());
698        let mut composed = ComposedKeyHandler::new(base);
699
700        // Can access inner handler
701        assert!(composed.inner().status_hint().is_none());
702
703        // Can mutate inner handler
704        let context = KeyContext {
705            input_empty: true,
706            is_processing: false,
707            widget_blocking: false,
708        };
709        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
710        composed.inner_mut().handle_key(ctrl_d, &context);
711
712        // Inner state changed
713        assert!(composed.inner().status_hint().is_some());
714    }
715}