rho-coding-agent 1.14.0

A lightweight agent harness inspired by Pi
Documentation
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use crate::{
    questionnaire::{
        QuestionnaireAnswer, QuestionnaireDefaultSelection, QuestionnaireQuestionKind,
        QuestionnaireResponse,
    },
    tui::questionnaire::{QuestionnaireQuestion, QuestionnaireRequest},
};

use super::*;

fn choice_question(id: &str) -> QuestionnaireQuestion {
    QuestionnaireQuestion {
        id: id.into(),
        question: format!("{id}?"),
        header: None,
        help: None,
        default: None,
        default_selection: QuestionnaireDefaultSelection::Selected,
        kind: QuestionnaireQuestionKind::Choice,
        required: true,
        choices: vec!["alpha".into(), "beta".into()],
        allow_other: false,
    }
}

fn confirm_question(id: &str) -> QuestionnaireQuestion {
    QuestionnaireQuestion {
        id: id.into(),
        question: format!("{id}?"),
        header: None,
        help: None,
        default: None,
        default_selection: QuestionnaireDefaultSelection::Selected,
        kind: QuestionnaireQuestionKind::Confirm,
        required: true,
        choices: Vec::new(),
        allow_other: false,
    }
}

#[test]
fn enter_advances_questions_and_submits_only_on_the_last() {
    let (reply_tx, mut reply_rx) = tokio::sync::oneshot::channel();
    let mut app = test_app();
    app.input_ui
        .set_composer(ComposerMode::Questionnaire(QuestionnaireComposer::new(
            QuestionnaireRequest {
                title: None,
                reason: None,
                questions: vec![choice_question("first"), confirm_question("second")],
            },
            QuestionnaireResponseChannel::new(reply_tx),
        )));
    let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);

    assert!(app.handle_questionnaire_key(enter).unwrap());
    assert!(
        matches!(app.input_ui.composer(), ComposerMode::Questionnaire(_)),
        "enter on the first question must not submit the form"
    );
    assert!(reply_rx.try_recv().is_err());

    assert!(app.handle_questionnaire_key(enter).unwrap());
    assert!(matches!(app.input_ui.composer(), ComposerMode::Input));
    assert_eq!(
        reply_rx.try_recv(),
        Ok(QuestionnaireReply::Answer(QuestionnaireResponse {
            answers: vec![
                QuestionnaireAnswer {
                    id: "first".into(),
                    answer: serde_json::json!("alpha"),
                },
                QuestionnaireAnswer {
                    id: "second".into(),
                    answer: serde_json::json!("yes"),
                },
            ],
        }))
    );
}

#[test]
fn resolving_questionnaire_clears_preexisting_shell_mode() {
    for submit in [true, false] {
        let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
        let mut app = test_app();
        app.input_ui
            .set_shell_mode(Some(InlineShellMode::ExcludeFromContext));
        app.input_ui
            .set_composer(ComposerMode::Questionnaire(QuestionnaireComposer::new(
                QuestionnaireRequest {
                    title: None,
                    reason: None,
                    questions: vec![choice_question("choice")],
                },
                QuestionnaireResponseChannel::new(reply_tx),
            )));

        let key = if submit {
            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
        } else {
            KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)
        };
        app.handle_questionnaire_key(key).unwrap();

        assert_eq!(app.input_ui.shell_mode(), None);
        assert!(app.input_ui.text().is_empty());
        assert!(matches!(app.input_ui.composer(), ComposerMode::Input));
    }
}

#[test]
fn second_ctrl_c_cancels_questionnaire_without_exiting_tui() {
    let (reply_tx, mut reply_rx) = tokio::sync::oneshot::channel();
    let mut app = test_app();
    app.input_ui
        .set_composer(ComposerMode::Questionnaire(QuestionnaireComposer::new(
            QuestionnaireRequest {
                title: None,
                reason: None,
                questions: vec![QuestionnaireQuestion {
                    id: "answer".into(),
                    question: "Continue?".into(),
                    header: None,
                    help: None,
                    default: None,
                    default_selection: QuestionnaireDefaultSelection::Selected,
                    kind: QuestionnaireQuestionKind::Confirm,
                    required: true,
                    choices: Vec::new(),
                    allow_other: false,
                }],
            },
            QuestionnaireResponseChannel::new(reply_tx),
        )));
    let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);

    assert!(app.handle_questionnaire_key(ctrl_c).unwrap());
    assert_eq!(app.status, "answer cleared; press ctrl-c again to cancel");
    assert!(app.handle_questionnaire_key(ctrl_c).unwrap());

    assert!(matches!(app.input_ui.composer(), ComposerMode::Input));
    assert!(!app.should_quit);
    assert_eq!(app.ctrl_c_streak, 0);
    assert_eq!(app.status, "answer cancelled");
    assert!(matches!(
        reply_rx.try_recv(),
        Ok(QuestionnaireReply::Cancelled(
            QuestionnaireCancelReason::UserCancelled
        ))
    ));
}