Skip to main content

recursive/tui/
keymap.rs

1//! Key → [`UserAction`] mapping.
2//!
3//! Goal-144 widens this from a thin `KeyCode` forwarder to a full
4//! `KeyEvent` forwarder so that modifier-aware bindings (the new
5//! `Ctrl+E` toggle on the most recent `ToolResult` block) are handled
6//! uniformly inside `App::handle_key`.
7//!
8//! Goal-145 will introduce mode-aware mapping (Insert / Command /
9//! Visual …) and that logic will grow here.
10
11use crossterm::event::KeyEvent;
12
13use crate::tui::app::App;
14use crate::tui::events::UserAction;
15
16/// Dispatch a key event onto the app state. Returns an optional
17/// [`UserAction`] the caller must forward to the agent worker.
18pub 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    // ── emacs-style cursor motion (Ctrl+B/F/P/N) ───────────────
79
80    #[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; // end of buffer; on "def" line, col 3
118        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; // on "abc" line, col 2
128        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    // ── Ctrl+J newline + Ctrl+Enter newline ─────────────────────
143
144    #[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}