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, KeyCombo, 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    /// Get a reference to the key bindings.
47    ///
48    /// This is used by widgets to check navigation keys against configured bindings.
49    /// The default implementation returns `bare_minimum` bindings.
50    fn bindings(&self) -> &KeyBindings;
51}
52
53/// Default key handler with configurable bindings.
54///
55/// This implementation uses [`KeyBindings`] to determine what actions
56/// to take for each key press. It handles the standard key processing
57/// flow while allowing customization of all bindings.
58///
59/// The handler manages exit confirmation state internally, so agents
60/// get the two-key exit flow (e.g., press Ctrl+D twice) without needing
61/// to track any state in the App.
62///
63/// # Custom Bindings
64///
65/// In addition to the standard bindings, you can add custom key bindings
66/// that trigger custom actions:
67///
68/// ```ignore
69/// let handler = DefaultKeyHandler::new(KeyBindings::emacs())
70///     .with_custom_binding(KeyCombo::ctrl('t'), || {
71///         AppKeyAction::custom(MyCustomAction::ToggleSomething)
72///     });
73/// ```
74pub struct DefaultKeyHandler {
75    bindings: KeyBindings,
76    exit_state: ExitState,
77    custom_bindings: Vec<(KeyCombo, Box<dyn Fn() -> AppKeyAction + Send + Sync>)>,
78}
79
80impl DefaultKeyHandler {
81    /// Create a new handler with the given bindings.
82    pub fn new(bindings: KeyBindings) -> Self {
83        Self {
84            bindings,
85            exit_state: ExitState::default(),
86            custom_bindings: Vec::new(),
87        }
88    }
89
90    /// Get a reference to the key bindings.
91    pub fn bindings(&self) -> &KeyBindings {
92        &self.bindings
93    }
94
95    /// Add a custom key binding that triggers a custom action.
96    ///
97    /// Custom bindings are checked before standard bindings, allowing
98    /// you to override default behavior or add new key combinations.
99    ///
100    /// # Arguments
101    ///
102    /// * `combo` - The key combination to bind
103    /// * `action_fn` - A function that returns the action to execute
104    ///
105    /// # Example
106    ///
107    /// ```ignore
108    /// let handler = DefaultKeyHandler::new(KeyBindings::emacs())
109    ///     .with_custom_binding(KeyCombo::ctrl('t'), || {
110    ///         AppKeyAction::custom(MyCustomAction::ToggleSomething)
111    ///     })
112    ///     .with_custom_binding(KeyCombo::alt('h'), || {
113    ///         AppKeyAction::custom(MyCustomAction::ShowHelp)
114    ///     });
115    /// ```
116    pub fn with_custom_binding<F>(mut self, combo: KeyCombo, action_fn: F) -> Self
117    where
118        F: Fn() -> AppKeyAction + Send + Sync + 'static,
119    {
120        self.custom_bindings.push((combo, Box::new(action_fn)));
121        self
122    }
123
124    /// Check if the given key matches the exit mode binding.
125    fn is_exit_key(&self, key: &KeyEvent) -> bool {
126        KeyBindings::matches_any(&self.bindings.enter_exit_mode, key)
127    }
128
129    /// Check if the given key matches any custom binding.
130    fn check_custom_binding(&self, key: &KeyEvent) -> Option<AppKeyAction> {
131        for (combo, action_fn) in &self.custom_bindings {
132            if combo.matches(key) {
133                return Some(action_fn());
134            }
135        }
136        None
137    }
138}
139
140impl Default for DefaultKeyHandler {
141    fn default() -> Self {
142        Self::new(KeyBindings::default())
143    }
144}
145
146impl KeyHandler for DefaultKeyHandler {
147    fn handle_key(&mut self, key: KeyEvent, context: &KeyContext) -> AppKeyResult {
148        // Check if exit confirmation has expired
149        if self.exit_state.is_expired() {
150            self.exit_state.reset();
151        }
152
153        // When a modal widget is blocking, let it handle most keys.
154        // Only intercept "force quit" type bindings.
155        if context.widget_blocking {
156            // Still allow force-quit (e.g., Ctrl+Q) even in modals
157            if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
158                return AppKeyResult::Action(AppKeyAction::Quit);
159            }
160            // Let the modal widget handle everything else
161            return AppKeyResult::NotHandled;
162        }
163
164        // When processing (spinner active), only allow interrupt and exit
165        if context.is_processing {
166            if KeyBindings::matches_any(&self.bindings.interrupt, &key) {
167                return AppKeyResult::Action(AppKeyAction::Interrupt);
168            }
169            if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
170                return AppKeyResult::Action(AppKeyAction::Quit);
171            }
172            // Handle exit mode: exit key to enter or confirm exit
173            if self.is_exit_key(&key) {
174                if self.exit_state.is_awaiting() {
175                    self.exit_state.reset();
176                    return AppKeyResult::Action(AppKeyAction::RequestExit);
177                } else if context.input_empty {
178                    self.exit_state = ExitState::awaiting_confirmation(
179                        self.bindings.exit_timeout_secs,
180                    );
181                    return AppKeyResult::Handled;
182                }
183            }
184            // Ignore all other keys during processing
185            return AppKeyResult::Handled;
186        }
187
188        // Check for exit mode confirmation (handler manages this internally)
189        if self.exit_state.is_awaiting() {
190            if self.is_exit_key(&key) {
191                self.exit_state.reset();
192                return AppKeyResult::Action(AppKeyAction::RequestExit);
193            }
194            // Any other key cancels exit mode
195            self.exit_state.reset();
196            // Fall through to normal handling
197        }
198
199        // Check custom bindings first (allows overriding standard bindings)
200        if let Some(action) = self.check_custom_binding(&key) {
201            return AppKeyResult::Action(action);
202        }
203
204        // Application-level bindings
205        if KeyBindings::matches_any(&self.bindings.force_quit, &key) {
206            return AppKeyResult::Action(AppKeyAction::Quit);
207        }
208        if KeyBindings::matches_any(&self.bindings.quit, &key) && context.input_empty {
209            return AppKeyResult::Action(AppKeyAction::Quit);
210        }
211        if self.is_exit_key(&key) {
212            if context.input_empty {
213                // Enter exit confirmation mode
214                self.exit_state = ExitState::awaiting_confirmation(
215                    self.bindings.exit_timeout_secs,
216                );
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            if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT {
266                return AppKeyResult::Action(AppKeyAction::InsertChar(c));
267            }
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<Box<dyn Fn(&KeyEvent, &KeyContext) -> Option<AppKeyResult> + Send>>,
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_bare_minimum_handler_quit() {
467        let mut handler = DefaultKeyHandler::default(); // Uses bare_minimum
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 (bare_minimum 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'), || {
530                AppKeyAction::custom("toggle")
531            });
532
533        let context = KeyContext {
534            input_empty: true,
535            is_processing: false,
536            widget_blocking: false,
537        };
538
539        // Ctrl+T should trigger the custom action
540        let ctrl_t = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL);
541        let result = handler.handle_key(ctrl_t, &context);
542
543        if let AppKeyResult::Action(AppKeyAction::Custom(any)) = result {
544            assert!(any.downcast_ref::<&str>().is_some());
545        } else {
546            panic!("Expected Custom action, got {:?}", result);
547        }
548    }
549
550    #[test]
551    fn test_custom_binding_overrides_standard() {
552        // Custom binding for Ctrl+P should override the standard move_up
553        let mut handler = DefaultKeyHandler::new(KeyBindings::emacs())
554            .with_custom_binding(KeyCombo::ctrl('p'), || {
555                AppKeyAction::custom("custom_up")
556            });
557
558        let context = KeyContext {
559            input_empty: true,
560            is_processing: false,
561            widget_blocking: false,
562        };
563
564        let ctrl_p = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL);
565        let result = handler.handle_key(ctrl_p, &context);
566
567        // Should get custom action, not MoveUp
568        if let AppKeyResult::Action(AppKeyAction::Custom(_)) = result {
569            // Good - custom binding took precedence
570        } else {
571            panic!("Expected Custom action to override MoveUp, got {:?}", result);
572        }
573    }
574
575    #[test]
576    fn test_composed_handler_basic() {
577        let base = DefaultKeyHandler::new(KeyBindings::minimal());
578        let mut composed = ComposedKeyHandler::new(base);
579
580        let context = KeyContext {
581            input_empty: true,
582            is_processing: false,
583            widget_blocking: false,
584        };
585
586        // Without hooks, should behave like the inner handler
587        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
588        let result = composed.handle_key(esc, &context);
589        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Quit));
590    }
591
592    #[test]
593    fn test_composed_handler_pre_hook_intercepts() {
594        let base = DefaultKeyHandler::new(KeyBindings::minimal());
595        let mut composed = ComposedKeyHandler::new(base)
596            .with_pre_hook(|key, _ctx| {
597                // Intercept F1 key
598                if key.code == KeyCode::F(1) {
599                    return Some(AppKeyResult::Action(AppKeyAction::custom("help")));
600                }
601                None
602            });
603
604        let context = KeyContext {
605            input_empty: true,
606            is_processing: false,
607            widget_blocking: false,
608        };
609
610        // F1 should be intercepted by the hook
611        let f1 = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
612        let result = composed.handle_key(f1, &context);
613
614        if let AppKeyResult::Action(AppKeyAction::Custom(_)) = result {
615            // Good - hook intercepted
616        } else {
617            panic!("Expected hook to intercept F1, got {:?}", result);
618        }
619
620        // Other keys should pass through to inner handler
621        let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
622        let result = composed.handle_key(esc, &context);
623        assert_eq!(result, AppKeyResult::Action(AppKeyAction::Quit));
624    }
625
626    #[test]
627    fn test_composed_handler_multiple_hooks() {
628        let base = DefaultKeyHandler::new(KeyBindings::minimal());
629        let mut composed = ComposedKeyHandler::new(base)
630            .with_pre_hook(|key, _ctx| {
631                // First hook intercepts F1
632                if key.code == KeyCode::F(1) {
633                    return Some(AppKeyResult::Action(AppKeyAction::custom("first")));
634                }
635                None
636            })
637            .with_pre_hook(|key, _ctx| {
638                // Second hook intercepts F2 and also F1 (but won't get F1)
639                if key.code == KeyCode::F(2) {
640                    return Some(AppKeyResult::Action(AppKeyAction::custom("second")));
641                }
642                if key.code == KeyCode::F(1) {
643                    return Some(AppKeyResult::Action(AppKeyAction::custom("should_not_see")));
644                }
645                None
646            });
647
648        let context = KeyContext {
649            input_empty: true,
650            is_processing: false,
651            widget_blocking: false,
652        };
653
654        // F1 should be handled by first hook
655        let f1 = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE);
656        let result = composed.handle_key(f1, &context);
657        if let AppKeyResult::Action(AppKeyAction::Custom(any)) = result {
658            let s = any.downcast_ref::<&str>().unwrap();
659            assert_eq!(*s, "first");
660        } else {
661            panic!("Expected first hook to handle F1");
662        }
663
664        // F2 should be handled by second hook
665        let f2 = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
666        let result = composed.handle_key(f2, &context);
667        if let AppKeyResult::Action(AppKeyAction::Custom(any)) = result {
668            let s = any.downcast_ref::<&str>().unwrap();
669            assert_eq!(*s, "second");
670        } else {
671            panic!("Expected second hook to handle F2");
672        }
673    }
674
675    #[test]
676    fn test_composed_handler_status_hint() {
677        // Inner handler's status_hint should be accessible
678        let mut base = DefaultKeyHandler::new(KeyBindings::emacs());
679
680        // Put base handler into exit mode
681        let context = KeyContext {
682            input_empty: true,
683            is_processing: false,
684            widget_blocking: false,
685        };
686        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
687        base.handle_key(ctrl_d, &context);
688
689        // Now wrap it
690        let composed = ComposedKeyHandler::new(base);
691
692        // Status hint should come from inner handler
693        assert!(composed.status_hint().is_some());
694        assert!(composed.status_hint().unwrap().contains("exit"));
695    }
696
697    #[test]
698    fn test_composed_handler_inner_access() {
699        let base = DefaultKeyHandler::new(KeyBindings::emacs());
700        let mut composed = ComposedKeyHandler::new(base);
701
702        // Can access inner handler
703        assert!(composed.inner().status_hint().is_none());
704
705        // Can mutate inner handler
706        let context = KeyContext {
707            input_empty: true,
708            is_processing: false,
709            widget_blocking: false,
710        };
711        let ctrl_d = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
712        composed.inner_mut().handle_key(ctrl_d, &context);
713
714        // Inner state changed
715        assert!(composed.inner().status_hint().is_some());
716    }
717}