npm_run_scripts/tui/
input.rs

1//! Input handling for the TUI.
2
3use anyhow::Result;
4use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
5
6use super::app::{App, AppMode};
7
8/// Handle a terminal event.
9///
10/// Returns `Ok(true)` if the app should quit, `Ok(false)` to continue.
11pub fn handle_event(app: &mut App, event: Event) -> Result<bool> {
12    match event {
13        Event::Key(key) => Ok(handle_key(app, key)),
14        Event::Resize(width, _height) => {
15            app.update_columns(width);
16            Ok(false)
17        }
18        _ => Ok(false),
19    }
20}
21
22/// Handle a key event.
23///
24/// Returns true if the app should quit.
25fn handle_key(app: &mut App, key: KeyEvent) -> bool {
26    // Global quit shortcuts (except in text input modes)
27    if matches!(
28        (key.code, key.modifiers),
29        (KeyCode::Char('c'), KeyModifiers::CONTROL)
30    ) && !matches!(app.mode(), AppMode::Filter { .. } | AppMode::Args { .. })
31    {
32        app.quit();
33        return true;
34    }
35
36    match app.mode().clone() {
37        AppMode::Normal => handle_normal_mode(app, key),
38        AppMode::Filter { query } => handle_filter_mode(app, key, &query),
39        AppMode::Help => handle_help_mode(app, key),
40        AppMode::Error { .. } => handle_error_mode(app, key),
41        AppMode::MultiSelect { selected } => handle_multiselect_mode(app, key, &selected),
42        AppMode::Args {
43            script_index,
44            input,
45        } => handle_args_mode(app, key, script_index, &input),
46        AppMode::WorkspaceSelect => handle_workspace_select_mode(app, key),
47    }
48
49    app.should_quit()
50}
51
52/// Handle keys in normal mode.
53///
54/// Navigation:
55/// - ↑/k: move up
56/// - ↓/j: move down
57/// - ←/h: move left
58/// - →/l: move right
59/// - Home/g: move to first
60/// - End/G: move to last
61///
62/// Actions:
63/// - Enter/o: run selected script
64/// - 1-9: run numbered script
65/// - /: enter filter mode
66/// - s: cycle sort mode
67/// - a: enter args mode
68/// - m: enter multi-select mode
69/// - ?: toggle help
70/// - q/Ctrl+C: quit
71fn handle_normal_mode(app: &mut App, key: KeyEvent) {
72    match key.code {
73        // Navigation
74        KeyCode::Up | KeyCode::Char('k') => app.move_up(),
75        KeyCode::Down | KeyCode::Char('j') => app.move_down(),
76        KeyCode::Left | KeyCode::Char('h') => app.move_left(),
77        KeyCode::Right | KeyCode::Char('l') => app.move_right(),
78        KeyCode::Home | KeyCode::Char('g') => app.move_to_first(),
79        KeyCode::End | KeyCode::Char('G') => app.move_to_last(),
80
81        // Run selected script
82        KeyCode::Enter | KeyCode::Char('o') => {
83            app.run_selected();
84        }
85
86        // Quick select (1-9)
87        KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
88            let num = c.to_digit(10).unwrap() as usize;
89            app.run_numbered(num);
90        }
91
92        // Enter filter mode
93        KeyCode::Char('/') => {
94            app.set_mode(AppMode::Filter {
95                query: String::new(),
96            });
97        }
98
99        // Cycle sort mode
100        KeyCode::Char('s') => {
101            app.cycle_sort_mode();
102        }
103
104        // Enter args mode
105        KeyCode::Char('a') => {
106            app.enter_args_mode();
107        }
108
109        // Enter multi-select mode
110        KeyCode::Char('m') => {
111            app.toggle_multi_select();
112        }
113
114        // Help
115        KeyCode::Char('?') => {
116            app.toggle_help();
117        }
118
119        // Quit
120        KeyCode::Char('q') => {
121            app.quit();
122        }
123
124        // Back to workspace selection (for monorepos)
125        KeyCode::Char('w') if app.is_monorepo() => {
126            app.back_to_workspace_select();
127        }
128
129        _ => {}
130    }
131}
132
133/// Handle keys in workspace selection mode.
134///
135/// Navigation:
136/// - ↑/k: move up
137/// - ↓/j: move down
138/// - ←/h: move left
139/// - →/l: move right
140///
141/// Actions:
142/// - Enter: select workspace and show its scripts
143/// - 1-9: quick select workspace
144/// - q/Esc: quit
145fn handle_workspace_select_mode(app: &mut App, key: KeyEvent) {
146    match key.code {
147        // Navigation
148        KeyCode::Up | KeyCode::Char('k') => app.workspace_move_up(),
149        KeyCode::Down | KeyCode::Char('j') => app.workspace_move_down(),
150        KeyCode::Left | KeyCode::Char('h') => app.workspace_move_left(),
151        KeyCode::Right | KeyCode::Char('l') => app.workspace_move_right(),
152
153        // Select workspace
154        KeyCode::Enter => {
155            app.select_current_workspace();
156        }
157
158        // Quick select (1-9)
159        KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
160            let num = c.to_digit(10).unwrap() as usize;
161            app.select_workspace_by_number(num);
162        }
163
164        // Help
165        KeyCode::Char('?') => {
166            app.toggle_help();
167        }
168
169        // Quit
170        KeyCode::Char('q') | KeyCode::Esc => {
171            app.quit();
172        }
173
174        _ => {}
175    }
176}
177
178/// Handle keys in filter mode.
179///
180/// - Printable characters: append to filter
181/// - Backspace: remove last character
182/// - Escape: clear filter and exit filter mode
183/// - Enter: run first visible script
184/// - Navigation keys still work while filtering
185fn handle_filter_mode(app: &mut App, key: KeyEvent, current_query: &str) {
186    match key.code {
187        // Exit filter mode
188        KeyCode::Esc => {
189            app.clear_filter();
190            app.set_mode(AppMode::Normal);
191        }
192
193        // Run selected script
194        KeyCode::Enter => {
195            app.run_selected();
196        }
197
198        // Remove last character
199        KeyCode::Backspace => {
200            let mut query = current_query.to_string();
201            query.pop();
202            if query.is_empty() {
203                app.clear_filter();
204                app.set_mode(AppMode::Normal);
205            } else {
206                app.set_filter(query);
207            }
208        }
209
210        // Navigation in filter mode (arrow keys)
211        KeyCode::Up => app.move_up(),
212        KeyCode::Down => app.move_down(),
213        KeyCode::Left => app.move_left(),
214        KeyCode::Right => app.move_right(),
215
216        // Append character (this must come after arrow keys)
217        KeyCode::Char(c) => {
218            // Quick select still works with empty filter and digits
219            if c.is_ascii_digit() && c != '0' && current_query.is_empty() {
220                let num = c.to_digit(10).unwrap() as usize;
221                app.run_numbered(num);
222            } else {
223                let mut query = current_query.to_string();
224                query.push(c);
225                app.set_filter(query);
226            }
227        }
228
229        _ => {}
230    }
231}
232
233/// Handle keys in help mode.
234///
235/// Any key closes help.
236fn handle_help_mode(app: &mut App, key: KeyEvent) {
237    match key.code {
238        KeyCode::Esc
239        | KeyCode::Char('?')
240        | KeyCode::Char('q')
241        | KeyCode::Enter
242        | KeyCode::Char(_) => {
243            app.set_mode(AppMode::Normal);
244        }
245        _ => {
246            // Any other key also dismisses help
247            app.set_mode(AppMode::Normal);
248        }
249    }
250}
251
252/// Handle keys in error mode.
253///
254/// Any key dismisses the error.
255fn handle_error_mode(app: &mut App, _key: KeyEvent) {
256    // Any key dismisses the error
257    app.set_mode(AppMode::Normal);
258}
259
260/// Handle keys in multi-select mode.
261///
262/// - Space: toggle current item selection
263/// - Enter: run all selected scripts in order
264/// - a: select all visible
265/// - n: select none
266/// - Escape: exit multi-select mode
267fn handle_multiselect_mode(
268    app: &mut App,
269    key: KeyEvent,
270    current_selected: &std::collections::HashSet<usize>,
271) {
272    match key.code {
273        // Exit multi-select mode
274        KeyCode::Esc => {
275            app.set_mode(AppMode::Normal);
276        }
277
278        // Toggle current item
279        KeyCode::Char(' ') => {
280            app.toggle_current_selection();
281        }
282
283        // Run all selected
284        KeyCode::Enter => {
285            app.run_multi_selected();
286        }
287
288        // Select all visible
289        KeyCode::Char('a') => {
290            let mut selected = current_selected.clone();
291            for i in 0..app.visible_count() {
292                selected.insert(i);
293            }
294            app.set_mode(AppMode::MultiSelect { selected });
295        }
296
297        // Select none
298        KeyCode::Char('n') => {
299            app.set_mode(AppMode::MultiSelect {
300                selected: std::collections::HashSet::new(),
301            });
302        }
303
304        // Navigation
305        KeyCode::Up | KeyCode::Char('k') => app.move_up(),
306        KeyCode::Down | KeyCode::Char('j') => app.move_down(),
307        KeyCode::Left | KeyCode::Char('h') => app.move_left(),
308        KeyCode::Right | KeyCode::Char('l') => app.move_right(),
309        KeyCode::Home | KeyCode::Char('g') => app.move_to_first(),
310        KeyCode::End | KeyCode::Char('G') => app.move_to_last(),
311
312        _ => {}
313    }
314}
315
316/// Handle keys in args input mode.
317///
318/// - Printable characters: append to args input
319/// - Backspace: remove last character
320/// - Enter: run script with args
321/// - Escape: cancel and return to normal mode
322fn handle_args_mode(app: &mut App, key: KeyEvent, script_index: usize, current_input: &str) {
323    match key.code {
324        // Cancel and return to normal mode
325        KeyCode::Esc => {
326            app.set_mode(AppMode::Normal);
327        }
328
329        // Run script with args
330        KeyCode::Enter => {
331            app.run_with_args(current_input.to_string());
332        }
333
334        // Remove last character
335        KeyCode::Backspace => {
336            let mut input = current_input.to_string();
337            input.pop();
338            app.set_mode(AppMode::Args {
339                script_index,
340                input,
341            });
342        }
343
344        // Append character
345        KeyCode::Char(c) => {
346            let mut input = current_input.to_string();
347            input.push(c);
348            app.set_mode(AppMode::Args {
349                script_index,
350                input,
351            });
352        }
353
354        _ => {}
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use crate::config::Config;
362    use crate::history::History;
363    use crate::package::{Runner, Script, Scripts};
364    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
365    use std::path::PathBuf;
366
367    fn create_test_scripts() -> Scripts {
368        let mut scripts = Scripts::new();
369        scripts.add(Script::new("dev", "vite"));
370        scripts.add(Script::new("build", "vite build"));
371        scripts.add(Script::new("test", "vitest"));
372        scripts.add(Script::new("lint", "eslint ."));
373        scripts.add(Script::new("format", "prettier --write ."));
374        scripts
375    }
376
377    fn create_test_app() -> App {
378        let scripts = create_test_scripts();
379        let config = Config::default();
380        let history = History::new();
381        App::new(
382            scripts,
383            config,
384            history,
385            "test-project".to_string(),
386            PathBuf::from("/test/project"),
387            Runner::Npm,
388        )
389    }
390
391    fn key_event(code: KeyCode) -> KeyEvent {
392        KeyEvent {
393            code,
394            modifiers: KeyModifiers::NONE,
395            kind: KeyEventKind::Press,
396            state: KeyEventState::NONE,
397        }
398    }
399
400    fn key_event_with_modifiers(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
401        KeyEvent {
402            code,
403            modifiers,
404            kind: KeyEventKind::Press,
405            state: KeyEventState::NONE,
406        }
407    }
408
409    // ==================== Normal Mode Tests ====================
410
411    #[test]
412    fn test_normal_mode_navigation_arrows() {
413        let mut app = create_test_app();
414        app.update_columns(100); // Multiple columns
415
416        handle_normal_mode(&mut app, key_event(KeyCode::Down));
417        assert!(app.selected_index() > 0 || app.visible_count() <= 1);
418
419        handle_normal_mode(&mut app, key_event(KeyCode::Up));
420        assert_eq!(app.selected_index(), 0);
421    }
422
423    #[test]
424    fn test_normal_mode_navigation_vim() {
425        let mut app = create_test_app();
426        app.update_columns(100);
427
428        handle_normal_mode(&mut app, key_event(KeyCode::Char('j')));
429        let after_j = app.selected_index();
430
431        handle_normal_mode(&mut app, key_event(KeyCode::Char('k')));
432        assert_eq!(app.selected_index(), 0);
433
434        // Left/right with h/l
435        handle_normal_mode(&mut app, key_event(KeyCode::Char('l')));
436        assert_eq!(app.selected_index(), 1);
437
438        handle_normal_mode(&mut app, key_event(KeyCode::Char('h')));
439        assert_eq!(app.selected_index(), 0);
440
441        // Verify j moved down
442        assert!(after_j > 0 || app.columns() == 1);
443    }
444
445    #[test]
446    fn test_normal_mode_navigation_home_end() {
447        let mut app = create_test_app();
448
449        handle_normal_mode(&mut app, key_event(KeyCode::End));
450        assert_eq!(app.selected_index(), app.visible_count() - 1);
451
452        handle_normal_mode(&mut app, key_event(KeyCode::Home));
453        assert_eq!(app.selected_index(), 0);
454    }
455
456    #[test]
457    fn test_normal_mode_navigation_g_shift_g() {
458        let mut app = create_test_app();
459
460        handle_normal_mode(&mut app, key_event(KeyCode::Char('G')));
461        assert_eq!(app.selected_index(), app.visible_count() - 1);
462
463        handle_normal_mode(&mut app, key_event(KeyCode::Char('g')));
464        assert_eq!(app.selected_index(), 0);
465    }
466
467    #[test]
468    fn test_normal_mode_run_selected() {
469        let mut app = create_test_app();
470
471        handle_normal_mode(&mut app, key_event(KeyCode::Enter));
472        assert!(app.should_quit());
473        assert!(app.script_to_run().is_some());
474    }
475
476    #[test]
477    fn test_normal_mode_run_selected_o() {
478        let mut app = create_test_app();
479
480        handle_normal_mode(&mut app, key_event(KeyCode::Char('o')));
481        assert!(app.should_quit());
482        assert!(app.script_to_run().is_some());
483    }
484
485    #[test]
486    fn test_normal_mode_quick_select() {
487        let mut app = create_test_app();
488
489        handle_normal_mode(&mut app, key_event(KeyCode::Char('3')));
490        assert!(app.should_quit());
491        assert!(app.script_to_run().is_some());
492        assert_eq!(app.selected_index(), 2);
493    }
494
495    #[test]
496    fn test_normal_mode_enter_filter() {
497        let mut app = create_test_app();
498
499        handle_normal_mode(&mut app, key_event(KeyCode::Char('/')));
500        assert!(matches!(app.mode(), AppMode::Filter { .. }));
501    }
502
503    #[test]
504    fn test_normal_mode_cycle_sort() {
505        let mut app = create_test_app();
506        let initial_sort = app.sort_mode();
507
508        handle_normal_mode(&mut app, key_event(KeyCode::Char('s')));
509        assert_ne!(app.sort_mode(), initial_sort);
510    }
511
512    #[test]
513    fn test_normal_mode_enter_args() {
514        let mut app = create_test_app();
515
516        handle_normal_mode(&mut app, key_event(KeyCode::Char('a')));
517        assert!(matches!(app.mode(), AppMode::Args { .. }));
518    }
519
520    #[test]
521    fn test_normal_mode_enter_multiselect() {
522        let mut app = create_test_app();
523
524        handle_normal_mode(&mut app, key_event(KeyCode::Char('m')));
525        assert!(matches!(app.mode(), AppMode::MultiSelect { .. }));
526    }
527
528    #[test]
529    fn test_normal_mode_toggle_help() {
530        let mut app = create_test_app();
531
532        handle_normal_mode(&mut app, key_event(KeyCode::Char('?')));
533        assert!(matches!(app.mode(), AppMode::Help));
534    }
535
536    #[test]
537    fn test_normal_mode_quit_q() {
538        let mut app = create_test_app();
539
540        handle_normal_mode(&mut app, key_event(KeyCode::Char('q')));
541        assert!(app.should_quit());
542    }
543
544    #[test]
545    fn test_normal_mode_quit_ctrl_c() {
546        let mut app = create_test_app();
547
548        let result = handle_key(
549            &mut app,
550            key_event_with_modifiers(KeyCode::Char('c'), KeyModifiers::CONTROL),
551        );
552        assert!(result);
553        assert!(app.should_quit());
554    }
555
556    // ==================== Filter Mode Tests ====================
557
558    #[test]
559    fn test_filter_mode_type_character() {
560        let mut app = create_test_app();
561        app.set_mode(AppMode::Filter {
562            query: String::new(),
563        });
564
565        handle_filter_mode(&mut app, key_event(KeyCode::Char('t')), "");
566        assert_eq!(app.filter_text(), "t");
567    }
568
569    #[test]
570    fn test_filter_mode_type_multiple_characters() {
571        let mut app = create_test_app();
572        app.set_mode(AppMode::Filter {
573            query: String::new(),
574        });
575
576        handle_filter_mode(&mut app, key_event(KeyCode::Char('t')), "");
577        handle_filter_mode(&mut app, key_event(KeyCode::Char('e')), "t");
578        handle_filter_mode(&mut app, key_event(KeyCode::Char('s')), "te");
579        assert_eq!(app.filter_text(), "tes");
580    }
581
582    #[test]
583    fn test_filter_mode_backspace() {
584        let mut app = create_test_app();
585        app.set_filter("test".to_string());
586
587        handle_filter_mode(&mut app, key_event(KeyCode::Backspace), "test");
588        assert_eq!(app.filter_text(), "tes");
589    }
590
591    #[test]
592    fn test_filter_mode_backspace_exits_when_empty() {
593        let mut app = create_test_app();
594        app.set_filter("t".to_string());
595
596        handle_filter_mode(&mut app, key_event(KeyCode::Backspace), "t");
597        assert!(matches!(app.mode(), AppMode::Normal));
598        assert_eq!(app.filter_text(), "");
599    }
600
601    #[test]
602    fn test_filter_mode_escape_clears_and_exits() {
603        let mut app = create_test_app();
604        app.set_filter("test".to_string());
605
606        handle_filter_mode(&mut app, key_event(KeyCode::Esc), "test");
607        assert!(matches!(app.mode(), AppMode::Normal));
608        assert_eq!(app.filter_text(), "");
609    }
610
611    #[test]
612    fn test_filter_mode_enter_runs_script() {
613        let mut app = create_test_app();
614        app.set_filter("dev".to_string());
615
616        handle_filter_mode(&mut app, key_event(KeyCode::Enter), "dev");
617        assert!(app.should_quit());
618        assert!(app.script_to_run().is_some());
619    }
620
621    #[test]
622    fn test_filter_mode_navigation() {
623        let mut app = create_test_app();
624        app.update_columns(100);
625        app.set_mode(AppMode::Filter {
626            query: String::new(),
627        });
628
629        handle_filter_mode(&mut app, key_event(KeyCode::Down), "");
630        let pos = app.selected_index();
631        assert!(pos > 0 || app.columns() == 1);
632
633        handle_filter_mode(&mut app, key_event(KeyCode::Up), "");
634        assert_eq!(app.selected_index(), 0);
635    }
636
637    #[test]
638    fn test_filter_mode_quick_select_empty_query() {
639        let mut app = create_test_app();
640        app.set_mode(AppMode::Filter {
641            query: String::new(),
642        });
643
644        handle_filter_mode(&mut app, key_event(KeyCode::Char('2')), "");
645        assert!(app.should_quit());
646        assert_eq!(app.selected_index(), 1);
647    }
648
649    // ==================== Multi-Select Mode Tests ====================
650
651    #[test]
652    fn test_multiselect_toggle_selection() {
653        let mut app = create_test_app();
654        app.toggle_multi_select();
655
656        handle_multiselect_mode(
657            &mut app,
658            key_event(KeyCode::Char(' ')),
659            &std::collections::HashSet::new(),
660        );
661        let selected = app.multi_selected_indices().unwrap();
662        assert!(selected.contains(&0));
663    }
664
665    #[test]
666    fn test_multiselect_run_selected() {
667        let mut app = create_test_app();
668        app.toggle_multi_select();
669        app.toggle_current_selection();
670
671        let selected = app.multi_selected_indices().unwrap().clone();
672        handle_multiselect_mode(&mut app, key_event(KeyCode::Enter), &selected);
673        assert!(app.should_quit());
674    }
675
676    #[test]
677    fn test_multiselect_select_all() {
678        let mut app = create_test_app();
679        app.toggle_multi_select();
680
681        handle_multiselect_mode(
682            &mut app,
683            key_event(KeyCode::Char('a')),
684            &std::collections::HashSet::new(),
685        );
686        let selected = app.multi_selected_indices().unwrap();
687        assert_eq!(selected.len(), app.visible_count());
688    }
689
690    #[test]
691    fn test_multiselect_select_none() {
692        let mut app = create_test_app();
693        app.toggle_multi_select();
694        app.toggle_current_selection();
695        app.move_right();
696        app.toggle_current_selection();
697
698        let current_selected = app.multi_selected_indices().unwrap().clone();
699        handle_multiselect_mode(&mut app, key_event(KeyCode::Char('n')), &current_selected);
700        let selected = app.multi_selected_indices().unwrap();
701        assert!(selected.is_empty());
702    }
703
704    #[test]
705    fn test_multiselect_escape() {
706        let mut app = create_test_app();
707        app.toggle_multi_select();
708
709        handle_multiselect_mode(
710            &mut app,
711            key_event(KeyCode::Esc),
712            &std::collections::HashSet::new(),
713        );
714        assert!(matches!(app.mode(), AppMode::Normal));
715    }
716
717    #[test]
718    fn test_multiselect_navigation() {
719        let mut app = create_test_app();
720        app.update_columns(100);
721        app.toggle_multi_select();
722
723        handle_multiselect_mode(
724            &mut app,
725            key_event(KeyCode::Char('j')),
726            &std::collections::HashSet::new(),
727        );
728        assert!(app.selected_index() > 0 || app.columns() == 1);
729
730        handle_multiselect_mode(
731            &mut app,
732            key_event(KeyCode::Char('k')),
733            &std::collections::HashSet::new(),
734        );
735        assert_eq!(app.selected_index(), 0);
736    }
737
738    // ==================== Args Mode Tests ====================
739
740    #[test]
741    fn test_args_mode_type_character() {
742        let mut app = create_test_app();
743        app.enter_args_mode();
744
745        handle_args_mode(&mut app, key_event(KeyCode::Char('-')), 0, "");
746
747        if let AppMode::Args { input, .. } = app.mode() {
748            assert_eq!(input, "-");
749        } else {
750            panic!("Expected Args mode");
751        }
752    }
753
754    #[test]
755    fn test_args_mode_type_multiple() {
756        let mut app = create_test_app();
757        app.enter_args_mode();
758
759        handle_args_mode(&mut app, key_event(KeyCode::Char('-')), 0, "");
760        handle_args_mode(&mut app, key_event(KeyCode::Char('-')), 0, "-");
761        handle_args_mode(&mut app, key_event(KeyCode::Char('w')), 0, "--");
762
763        if let AppMode::Args { input, .. } = app.mode() {
764            assert_eq!(input, "--w");
765        } else {
766            panic!("Expected Args mode");
767        }
768    }
769
770    #[test]
771    fn test_args_mode_backspace() {
772        let mut app = create_test_app();
773        app.set_mode(AppMode::Args {
774            script_index: 0,
775            input: "--watch".to_string(),
776        });
777
778        handle_args_mode(&mut app, key_event(KeyCode::Backspace), 0, "--watch");
779
780        if let AppMode::Args { input, .. } = app.mode() {
781            assert_eq!(input, "--watc");
782        } else {
783            panic!("Expected Args mode");
784        }
785    }
786
787    #[test]
788    fn test_args_mode_enter_runs_with_args() {
789        let mut app = create_test_app();
790        app.set_mode(AppMode::Args {
791            script_index: 0,
792            input: "--watch".to_string(),
793        });
794
795        handle_args_mode(&mut app, key_event(KeyCode::Enter), 0, "--watch");
796        assert!(app.should_quit());
797
798        let run = app.script_to_run().unwrap();
799        assert_eq!(run.args, Some("--watch".to_string()));
800    }
801
802    #[test]
803    fn test_args_mode_escape_cancels() {
804        let mut app = create_test_app();
805        app.set_mode(AppMode::Args {
806            script_index: 0,
807            input: "--watch".to_string(),
808        });
809
810        handle_args_mode(&mut app, key_event(KeyCode::Esc), 0, "--watch");
811        assert!(matches!(app.mode(), AppMode::Normal));
812        assert!(!app.should_quit());
813    }
814
815    // ==================== Help Mode Tests ====================
816
817    #[test]
818    fn test_help_mode_any_key_closes() {
819        let mut app = create_test_app();
820        app.set_mode(AppMode::Help);
821
822        handle_help_mode(&mut app, key_event(KeyCode::Char('x')));
823        assert!(matches!(app.mode(), AppMode::Normal));
824    }
825
826    #[test]
827    fn test_help_mode_escape_closes() {
828        let mut app = create_test_app();
829        app.set_mode(AppMode::Help);
830
831        handle_help_mode(&mut app, key_event(KeyCode::Esc));
832        assert!(matches!(app.mode(), AppMode::Normal));
833    }
834
835    // ==================== Error Mode Tests ====================
836
837    #[test]
838    fn test_error_mode_any_key_dismisses() {
839        let mut app = create_test_app();
840        app.set_mode(AppMode::Error {
841            message: "Test error".to_string(),
842        });
843
844        handle_error_mode(&mut app, key_event(KeyCode::Enter));
845        assert!(matches!(app.mode(), AppMode::Normal));
846    }
847
848    // ==================== Resize Tests ====================
849
850    #[test]
851    fn test_resize_updates_columns() {
852        let mut app = create_test_app();
853        app.update_columns(50);
854        assert_eq!(app.columns(), 1);
855
856        let result = handle_event(&mut app, Event::Resize(100, 50)).unwrap();
857        assert!(!result);
858        assert_eq!(app.columns(), 3);
859    }
860
861    // ==================== handle_event Tests ====================
862
863    #[test]
864    fn test_handle_event_key() {
865        let mut app = create_test_app();
866
867        let result = handle_event(&mut app, Event::Key(key_event(KeyCode::Char('q')))).unwrap();
868        assert!(result);
869        assert!(app.should_quit());
870    }
871
872    #[test]
873    fn test_handle_event_unknown() {
874        let mut app = create_test_app();
875
876        // FocusGained is an unknown event
877        let result = handle_event(&mut app, Event::FocusGained).unwrap();
878        assert!(!result);
879        assert!(!app.should_quit());
880    }
881}