thndrs 0.1.0

Terminal AI pair programmer with local tools, sessions, MCP, and ACP support
Documentation
use super::*;
use crate::input::PromptInput;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use helpers::*;

#[test]
fn q_appends_to_input_and_does_not_quit() {
    let mut app = fresh_app();
    let follow = update(
        &mut app,
        &Msg::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
    );
    assert!(!app.quit, "q should not quit");
    assert_eq!(app.input.as_str(), "q", "q should append to input");
    assert_eq!(follow, None);
}

#[test]
fn ctrl_d_works_even_with_input() {
    let mut app = fresh_app();
    app.input = PromptInput::from("some text");
    update(
        &mut app,
        &Msg::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
    );
    assert!(!app.quit);
    assert!(app.ctrl_d_pending.is_some());

    update(
        &mut app,
        &Msg::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)),
    );
    assert!(app.quit, "Ctrl+D should quit even with input present");
}

#[test]
fn file_picker_escape_closes_without_changing_input() {
    let mut app = fresh_app();
    app.input = PromptInput::from("read");
    app.prompt_accessory = PromptAccessory::Files(FilePickerSource::Forced);
    app.picker = Some(picker_from_paths(vec!["README.md".to_string()]));

    update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));

    assert_eq!(app.mode, Mode::Prompt);
    assert_eq!(app.prompt_accessory, PromptAccessory::None);
    assert_eq!(app.input.as_str(), "read");
    assert!(app.picker.is_none());
}

#[test]
fn printable_chars_append_to_input() {
    let mut app = fresh_app();
    for ch in "hello".chars() {
        update(
            &mut app,
            &Msg::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
        );
    }
    assert_eq!(app.input.as_str(), "hello");
    assert!(app.transcript.is_empty());
}

#[test]
fn backspace_on_empty_input_is_noop() {
    let mut app = fresh_app();
    update(
        &mut app,
        &Msg::Key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
    );
    assert_eq!(app.input.as_str(), "");
}

#[test]
fn enter_submits_user_entry_and_clears_input() {
    let mut app = fresh_app();
    app.input = PromptInput::from("explain this repo");
    update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
    assert_eq!(app.input.as_str(), "");
    assert_eq!(app.transcript.len(), 1);
    assert_eq!(
        app.transcript[0],
        Entry::User { text: String::from("explain this repo") }
    );
}

#[test]
fn enter_on_empty_input_does_nothing() {
    let mut app = fresh_app();
    update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
    assert_eq!(app.input.as_str(), "");
    assert!(app.transcript.is_empty());
}

#[test]
fn q_does_not_quit_even_when_input_empty() {
    let mut app = fresh_app();
    let follow = update(
        &mut app,
        &Msg::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
    );
    assert!(!app.quit, "q should never quit");
    assert_eq!(follow, None);
    assert_eq!(app.input.as_str(), "q");
}

#[test]
fn queued_running_input_is_recorded_in_history() {
    let mut app = fresh_app();
    app.run_state = RunState::Working;
    app.input = PromptInput::from("steer here");
    update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
    assert_eq!(app.input_history, vec![String::from("steer here")]);
}

#[test]
fn failed_provider_restores_input() {
    let mut app = fresh_app();
    app.input = PromptInput::from("hello world");
    update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
    assert!(app.input.is_empty(), "input should be cleared after submit");
    assert_eq!(app.last_input, Some("hello world".to_string()));

    update(&mut app, &Msg::Agent(AgentEvent::Failed(String::from("boom"))));
    assert_eq!(app.input.as_str(), "hello world", "input should be restored on failure");
    assert_eq!(app.run_state, RunState::Error("boom".to_string()));
}

#[test]
fn finished_clears_last_input() {
    let mut app = fresh_app();
    app.input = PromptInput::from("test prompt");
    update(&mut app, &Msg::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)));
    assert!(app.last_input.is_some());

    update(&mut app, &Msg::Agent(AgentEvent::Finished));
    assert!(app.last_input.is_none(), "last_input should be cleared on finish");
    assert!(app.input.is_empty(), "input should remain empty on finish");
}

#[test]
fn typing_while_streaming_appends_to_input() {
    let mut app = working_app_with_streaming();
    app.input = PromptInput::from("hel");

    update(&mut app, &key(KeyCode::Char('l'), KeyModifiers::NONE));
    update(&mut app, &key(KeyCode::Char('o'), KeyModifiers::NONE));
    assert_eq!(app.input.as_str(), "hello", "typing should work while streaming");
}

#[test]
fn multiline_input_while_working() {
    let mut app = working_app_with_streaming();
    app.input = PromptInput::from("line one");

    update(&mut app, &key(KeyCode::Enter, KeyModifiers::SHIFT));

    assert!(
        app.input.as_str().contains('\n'),
        "Shift+Enter should insert newline while working"
    );
    assert_eq!(app.run_state, RunState::Working, "run state should not change");
}

#[test]
fn queued_input_persisted_to_session_writer() {
    let dir = tempfile::tempdir().expect("create temp dir");
    let cli = Cli { cwd: dir.path().to_path_buf(), model: "umans-coder".to_string(), ..Cli::default() };
    let mut app = App::from_cli(&cli);
    app.first_run_recovery = None;
    app.run_state = RunState::Working;
    app.queue_target = QueueTarget::FollowUp;
    app.input = PromptInput::from("persisted follow-up");

    update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));

    let session_path = app
        .session_writer
        .as_ref()
        .expect("session writer should exist")
        .path()
        .to_path_buf();
    let content = std::fs::read_to_string(&session_path).expect("read session file");
    assert!(
        content.contains("queued_input"),
        "session file should contain a queued_input record: {content}"
    );
    assert!(
        content.contains("persisted follow-up"),
        "session file should contain the queued text: {content}"
    );
    assert!(
        content.contains("follow-up"),
        "session file should contain the kind field: {content}"
    );
}

#[test]
fn queued_input_append_failure_is_visible() {
    let dir = tempfile::tempdir().expect("create temp dir");
    let cli = Cli { cwd: dir.path().to_path_buf(), model: "umans-coder".to_string(), ..Cli::default() };
    let mut app = App::from_cli(&cli);
    app.first_run_recovery = None;
    let session_path = app
        .session_writer
        .as_ref()
        .expect("session writer should exist")
        .path()
        .to_path_buf();
    std::fs::remove_file(&session_path).expect("remove session file to force append failure");
    app.run_state = RunState::Working;
    app.queue_target = QueueTarget::FollowUp;
    app.input = PromptInput::from("cannot audit this");

    update(&mut app, &key(KeyCode::Enter, KeyModifiers::NONE));

    assert_eq!(app.queued_followups, vec!["cannot audit this".to_string()]);
    assert!(
        app.transcript
            .iter()
            .any(|e| matches!(e, Entry::Error { text } if text.contains("failed to record queued follow-up"))),
        "append failure should be surfaced in the transcript"
    );
}