1use crossterm::event::KeyEvent;
12
13use crate::tui::app::App;
14use crate::tui::events::UserAction;
15
16pub fn dispatch(app: &mut App, key: KeyEvent) -> Option<UserAction> {
19 app.handle_key(key)
20}
21
22#[cfg(test)]
23mod tests {
24 use super::*;
25 use crate::tui::app::{AppScreen, ToolResultData, TranscriptBlock};
26 use crossterm::event::{KeyCode, KeyModifiers};
27
28 fn k(code: KeyCode) -> KeyEvent {
29 KeyEvent::new(code, KeyModifiers::NONE)
30 }
31
32 fn ctrl(c: char) -> KeyEvent {
33 KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
34 }
35
36 #[test]
37 fn dispatch_routes_chat_enter_to_send_message() {
38 let mut app = App::new();
39 app.screen = AppScreen::Chat;
40 app.set_input("ping");
41 let action = dispatch(&mut app, k(KeyCode::Enter));
42 assert!(matches!(action, Some(UserAction::SendMessage(s)) if s == "ping"));
43 }
44
45 #[test]
46 fn dispatch_routes_chat_typing_to_no_action() {
47 let mut app = App::new();
48 app.screen = AppScreen::Chat;
49 let action = dispatch(&mut app, k(KeyCode::Char('a')));
50 assert!(action.is_none());
51 assert_eq!(app.input(), "a");
52 }
53
54 #[test]
55 fn dispatch_ctrl_e_toggles_last_tool_result() {
56 let mut app = App::new();
57 app.screen = AppScreen::Chat;
58 app.blocks.push(TranscriptBlock::ToolCall {
59 id: "1".into(),
60 name: "read_file".into(),
61 args_preview: String::new(),
62 result: Some(ToolResultData {
63 success: true,
64 output: "abc".into(),
65 expanded: false,
66 }),
67 });
68 let _ = dispatch(&mut app, ctrl('e'));
69 match app.blocks.last() {
70 Some(TranscriptBlock::ToolCall {
71 result: Some(ToolResultData { expanded, .. }),
72 ..
73 }) => assert!(*expanded),
74 other => panic!("expected ToolCall with Some(result), got {other:?}"),
75 }
76 }
77
78 #[test]
81 fn dispatch_ctrl_b_moves_cursor_left() {
82 let mut app = App::new();
83 app.screen = AppScreen::Chat;
84 app.set_input("abc");
85 let _ = dispatch(&mut app, ctrl('b'));
86 assert_eq!(app.prompt.cursor, 2);
87 }
88
89 #[test]
90 fn dispatch_ctrl_f_moves_cursor_right() {
91 let mut app = App::new();
92 app.screen = AppScreen::Chat;
93 app.set_input("abc");
94 app.prompt.cursor = 0;
95 let _ = dispatch(&mut app, ctrl('f'));
96 assert_eq!(app.prompt.cursor, 1);
97 }
98
99 #[test]
100 fn dispatch_ctrl_b_does_not_scroll_transcript() {
101 let mut app = App::new();
102 app.screen = AppScreen::Chat;
103 app.set_input("hello");
104 let before = app.scroll_offset;
105 let _ = dispatch(&mut app, ctrl('b'));
106 assert_eq!(
107 app.scroll_offset, before,
108 "Ctrl+B should not change scroll_offset"
109 );
110 }
111
112 #[test]
113 fn dispatch_ctrl_p_moves_cursor_to_previous_line() {
114 let mut app = App::new();
115 app.screen = AppScreen::Chat;
116 app.set_input("abc\ndef");
117 app.prompt.cursor = 6; let _ = dispatch(&mut app, ctrl('p'));
119 assert_eq!(app.prompt.cursor, 2, "should land on 'ab|c'");
120 }
121
122 #[test]
123 fn dispatch_ctrl_n_moves_cursor_to_next_line() {
124 let mut app = App::new();
125 app.screen = AppScreen::Chat;
126 app.set_input("abc\ndef");
127 app.prompt.cursor = 2; let _ = dispatch(&mut app, ctrl('n'));
129 assert_eq!(app.prompt.cursor, 6, "should land on 'de|f'");
130 }
131
132 #[test]
133 fn dispatch_ctrl_n_in_single_line_is_noop() {
134 let mut app = App::new();
135 app.screen = AppScreen::Chat;
136 app.set_input("hello");
137 app.prompt.cursor = 2;
138 let _ = dispatch(&mut app, ctrl('n'));
139 assert_eq!(app.prompt.cursor, 2, "single line is a no-op");
140 }
141
142 #[test]
145 fn dispatch_ctrl_j_inserts_newline_without_submitting() {
146 let mut app = App::new();
147 app.screen = AppScreen::Chat;
148 app.set_input("hello");
149 let action = dispatch(&mut app, ctrl('j'));
150 assert!(action.is_none(), "Ctrl+J must not submit");
151 assert_eq!(app.prompt.buffer, "hello\n");
152 }
153
154 #[test]
155 fn dispatch_ctrl_enter_inserts_newline_without_submitting() {
156 let mut app = App::new();
157 app.screen = AppScreen::Chat;
158 app.set_input("hello");
159 let action = dispatch(
160 &mut app,
161 KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
162 );
163 assert!(action.is_none(), "Ctrl+Enter must not submit");
164 assert_eq!(app.prompt.buffer, "hello\n");
165 }
166
167 #[test]
168 fn dispatch_plain_enter_still_submits() {
169 let mut app = App::new();
170 app.screen = AppScreen::Chat;
171 app.set_input("ping");
172 let action = dispatch(&mut app, k(KeyCode::Enter));
173 assert!(matches!(action, Some(UserAction::SendMessage(s)) if s == "ping"));
174 }
175}