recursive-agent 0.6.0

A minimal, orthogonal, self-improving coding agent kernel in Rust
Documentation
//! Key → [`UserAction`] mapping.
//!
//! Goal-144 widens this from a thin `KeyCode` forwarder to a full
//! `KeyEvent` forwarder so that modifier-aware bindings (the new
//! `Ctrl+E` toggle on the most recent `ToolResult` block) are handled
//! uniformly inside `App::handle_key`.
//!
//! Goal-145 will introduce mode-aware mapping (Insert / Command /
//! Visual …) and that logic will grow here.

use crossterm::event::KeyEvent;

use crate::tui::app::App;
use crate::tui::events::UserAction;

/// Dispatch a key event onto the app state. Returns an optional
/// [`UserAction`] the caller must forward to the agent worker.
pub fn dispatch(app: &mut App, key: KeyEvent) -> Option<UserAction> {
    app.handle_key(key)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tui::app::{AppScreen, ToolResultData, TranscriptBlock};
    use crossterm::event::{KeyCode, KeyModifiers};

    fn k(code: KeyCode) -> KeyEvent {
        KeyEvent::new(code, KeyModifiers::NONE)
    }

    fn ctrl(c: char) -> KeyEvent {
        KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
    }

    #[test]
    fn dispatch_routes_chat_enter_to_send_message() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("ping");
        let action = dispatch(&mut app, k(KeyCode::Enter));
        assert!(matches!(action, Some(UserAction::SendMessage(s)) if s == "ping"));
    }

    #[test]
    fn dispatch_routes_chat_typing_to_no_action() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        let action = dispatch(&mut app, k(KeyCode::Char('a')));
        assert!(action.is_none());
        assert_eq!(app.input(), "a");
    }

    #[test]
    fn dispatch_ctrl_e_toggles_last_tool_result() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.blocks.push(TranscriptBlock::ToolCall {
            id: "1".into(),
            name: "read_file".into(),
            args_preview: String::new(),
            result: Some(ToolResultData {
                success: true,
                output: "abc".into(),
                expanded: false,
            }),
        });
        let _ = dispatch(&mut app, ctrl('e'));
        match app.blocks.last() {
            Some(TranscriptBlock::ToolCall {
                result: Some(ToolResultData { expanded, .. }),
                ..
            }) => assert!(*expanded),
            other => panic!("expected ToolCall with Some(result), got {other:?}"),
        }
    }

    // ── emacs-style cursor motion (Ctrl+B/F/P/N) ───────────────

    #[test]
    fn dispatch_ctrl_b_moves_cursor_left() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("abc");
        let _ = dispatch(&mut app, ctrl('b'));
        assert_eq!(app.prompt.cursor, 2);
    }

    #[test]
    fn dispatch_ctrl_f_moves_cursor_right() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("abc");
        app.prompt.cursor = 0;
        let _ = dispatch(&mut app, ctrl('f'));
        assert_eq!(app.prompt.cursor, 1);
    }

    #[test]
    fn dispatch_ctrl_b_does_not_scroll_transcript() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("hello");
        let before = app.scroll_offset;
        let _ = dispatch(&mut app, ctrl('b'));
        assert_eq!(
            app.scroll_offset, before,
            "Ctrl+B should not change scroll_offset"
        );
    }

    #[test]
    fn dispatch_ctrl_p_moves_cursor_to_previous_line() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("abc\ndef");
        app.prompt.cursor = 6; // end of buffer; on "def" line, col 3
        let _ = dispatch(&mut app, ctrl('p'));
        assert_eq!(app.prompt.cursor, 2, "should land on 'ab|c'");
    }

    #[test]
    fn dispatch_ctrl_n_moves_cursor_to_next_line() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("abc\ndef");
        app.prompt.cursor = 2; // on "abc" line, col 2
        let _ = dispatch(&mut app, ctrl('n'));
        assert_eq!(app.prompt.cursor, 6, "should land on 'de|f'");
    }

    #[test]
    fn dispatch_ctrl_n_in_single_line_is_noop() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("hello");
        app.prompt.cursor = 2;
        let _ = dispatch(&mut app, ctrl('n'));
        assert_eq!(app.prompt.cursor, 2, "single line is a no-op");
    }

    // ── Ctrl+J newline + Ctrl+Enter newline ─────────────────────

    #[test]
    fn dispatch_ctrl_j_inserts_newline_without_submitting() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("hello");
        let action = dispatch(&mut app, ctrl('j'));
        assert!(action.is_none(), "Ctrl+J must not submit");
        assert_eq!(app.prompt.buffer, "hello\n");
    }

    #[test]
    fn dispatch_ctrl_enter_inserts_newline_without_submitting() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("hello");
        let action = dispatch(
            &mut app,
            KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
        );
        assert!(action.is_none(), "Ctrl+Enter must not submit");
        assert_eq!(app.prompt.buffer, "hello\n");
    }

    #[test]
    fn dispatch_plain_enter_still_submits() {
        let mut app = App::new();
        app.screen = AppScreen::Chat;
        app.set_input("ping");
        let action = dispatch(&mut app, k(KeyCode::Enter));
        assert!(matches!(action, Some(UserAction::SendMessage(s)) if s == "ping"));
    }
}