aether-wisp 0.2.0

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use agent_client_protocol as acp;
use tui::KeyCode;
use tui::KeyEvent;
use tui::KeyEventKind;
use tui::KeyEventState;
use tui::KeyModifiers;
use tui::testing::TestTerminal;

use super::common::*;

#[tokio::test]
async fn test_settings_command_opens_menu() {
    let r = open_settings(&make_settings_options(), (80, 24)).await;
    assert!(has_settings_menu(r.writer()), "Settings menu should be visible");
    assert!(!has_settings_picker(r.writer()), "Settings picker should not be visible");
}

fn make_provider_auth_methods() -> Vec<acp::AuthMethod> {
    vec![
        acp::AuthMethod::Agent(acp::AuthMethodAgent::new("anthropic", "Anthropic")),
        acp::AuthMethod::Agent(acp::AuthMethodAgent::new("openrouter", "OpenRouter")),
    ]
}

#[tokio::test]
async fn test_auth_methods_updated_notification_refreshes_provider_login_and_persists_on_reopen() {
    let terminal = TestTerminal::new(TEST_WIDTH, 40);
    let mut r = Renderer::new_with_auth_methods(
        terminal,
        TEST_AGENT.to_string(),
        &[],
        make_provider_auth_methods(),
        (TEST_WIDTH, 40),
    );
    r.initial_render().unwrap();
    type_string(&mut r, "/settings").await;
    press_enter(&mut r).await;
    press_down(&mut r).await;
    press_down(&mut r).await;

    press_enter(&mut r).await;
    assert_buffer_contains(r.writer(), "Anthropic  ⚡ needs login");

    let updated = vec![
        acp::AuthMethod::Agent(acp::AuthMethodAgent::new("anthropic", "Anthropic").description("authenticated")),
        acp::AuthMethod::Agent(acp::AuthMethodAgent::new("openrouter", "OpenRouter")),
    ];
    r.on_ext_notification(acp_utils::notifications::AuthMethodsUpdatedParams { auth_methods: updated }.into()).unwrap();
    assert_buffer_contains(r.writer(), "Anthropic  ✓ logged in");

    press_esc(&mut r).await;
    assert!(has_settings_menu(r.writer()));
    press_enter(&mut r).await;
    assert_buffer_contains(r.writer(), "Anthropic  ✓ logged in");
}

#[tokio::test]
async fn test_settings_menu_esc_closes() {
    let mut r = open_settings(&make_settings_options(), (80, 24)).await;
    assert!(has_settings_menu(r.writer()));
    assert!(!has_settings_picker(r.writer()));

    // Open the picker
    press_enter(&mut r).await;
    assert!(has_settings_menu(r.writer()));
    assert!(has_settings_picker(r.writer()));

    // First ESC closes picker
    press_esc(&mut r).await;
    assert!(has_settings_menu(r.writer()));
    assert!(!has_settings_picker(r.writer()));

    // Second ESC closes menu
    press_esc(&mut r).await;
    assert!(!has_settings_menu(r.writer()));
}

#[tokio::test]
async fn test_settings_menu_arrow_navigation_single_entry() {
    let mut r = open_settings(&make_settings_options(), (80, 24)).await;
    assert!(has_settings_menu(r.writer()));

    press_enter(&mut r).await;
    assert_buffer_contains(r.writer(), "Model search");

    press_esc(&mut r).await;
    press_down(&mut r).await;
    press_enter(&mut r).await;
    assert_buffer_contains(r.writer(), "Theme search");

    press_esc(&mut r).await;
    press_down(&mut r).await;
    press_enter(&mut r).await;
    assert_buffer_contains(r.writer(), "no MCP servers configured");

    press_esc(&mut r).await;
    press_down(&mut r).await;
    press_enter(&mut r).await;
    assert_buffer_contains(r.writer(), "Model search");
}

#[tokio::test]
async fn test_settings_single_option_shows_model_picker() {
    let mut r = open_settings(&make_settings_options(), (80, 24)).await;
    press_enter(&mut r).await;
    assert!(has_settings_picker(r.writer()));
    assert_buffer_contains(r.writer(), "Model search");
}

#[tokio::test]
async fn test_settings_picker_focuses_cursor_on_overlay_query() {
    let mut r = open_settings(&make_settings_options(), (80, 24)).await;
    press_enter(&mut r).await;

    let lines = r.writer().get_lines();
    #[allow(clippy::cast_possible_truncation)]
    let search_row = lines.iter().position(|l| l.contains("Model search:")).expect("search row") as u16;
    let (cursor_col, cursor_row) = r.writer().cursor_position();
    assert_eq!(cursor_row, search_row);
    assert_eq!(cursor_col, 18);
}

#[tokio::test]
async fn test_settings_picker_filters_model_options() {
    let mut r = open_settings(&make_settings_options(), (80, 24)).await;
    press_enter(&mut r).await;
    type_string(&mut r, "claude").await;
    assert_buffer_contains(r.writer(), "Claude Sonnet");
}

#[tokio::test]
async fn test_settings_menu_swallows_other_keys() {
    let config = vec![
        acp::SessionConfigOption::select("model", "Model", "m1", vec![acp::SessionConfigSelectOption::new("m1", "M1")]),
        acp::SessionConfigOption::select(
            "theme",
            "Theme",
            "dark",
            vec![acp::SessionConfigSelectOption::new("dark", "Dark")],
        ),
    ];
    let mut r = open_settings(&config, (80, 24)).await;
    send_key(&mut r, KeyCode::Char('z'), KeyModifiers::empty()).await;
    assert!(has_settings_menu(r.writer()));
    assert_buffer_not_contains(r.writer(), "z");
}

#[tokio::test]
async fn test_settings_menu_ctrl_c_exits() {
    let mut r = open_settings(&make_settings_options(), (80, 24)).await;
    let action = r
        .on_key_event(KeyEvent {
            code: KeyCode::Char('c'),
            modifiers: KeyModifiers::CONTROL,
            kind: KeyEventKind::Press,
            state: KeyEventState::empty(),
        })
        .await
        .unwrap();
    assert!(matches!(action, LoopAction::Continue), "first Ctrl-C should not exit");
    let action = r
        .on_key_event(KeyEvent {
            code: KeyCode::Char('c'),
            modifiers: KeyModifiers::CONTROL,
            kind: KeyEventKind::Press,
            state: KeyEventState::empty(),
        })
        .await
        .unwrap();
    assert!(matches!(action, LoopAction::Exit), "second Ctrl-C should exit");
}

#[tokio::test]
async fn test_settings_menu_updates_on_config_option_event() {
    let mut r = open_settings(&make_settings_options(), (80, 24)).await;
    let new_config = vec![
        acp::SessionConfigOption::select(
            "model",
            "Model",
            "anthropic:claude-sonnet-4-5",
            vec![
                acp::SessionConfigSelectOption::new("openrouter:openai/gpt-4o", "OpenRouter / GPT-4o"),
                acp::SessionConfigSelectOption::new("anthropic:claude-sonnet-4-5", "Anthropic / Claude Sonnet 4.5"),
            ],
        )
        .category(acp::SessionConfigOptionCategory::Model),
    ];
    r.on_session_update(acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate::new(new_config))).unwrap();
    assert_buffer_contains(r.writer(), "Claude Sonnet");
}

#[tokio::test]
async fn test_settings_clears_input_buffer() {
    let r = open_settings(&make_settings_options(), (80, 24)).await;
    assert_buffer_not_contains(r.writer(), "/settings");
}

#[tokio::test]
async fn test_settings_with_no_options_shows_placeholder() {
    let r = open_settings(&[], (80, 24)).await;
    assert!(has_settings_menu(r.writer()));
    assert_buffer_contains(r.writer(), "MCP Servers");
}

#[tokio::test]
async fn test_settings_overlay_renders_after_large_overflow_scrollback() {
    let config_options = make_settings_options();
    let terminal = TestTerminal::new(80, 8);
    let mut r = Renderer::new(terminal, TEST_AGENT.to_string(), &config_options, (80, 8));
    r.initial_render().unwrap();

    for i in 0..50 {
        r.on_session_update(acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
            acp::TextContent::new(format!("Line {i:02} with enough content to wrap in 40 cols")),
        ))))
        .unwrap();
    }

    type_string(&mut r, "/settings").await;
    press_enter(&mut r).await;
    assert!(has_settings_menu(r.writer()));
    assert_buffer_contains(r.writer(), "Configuration");
    assert_buffer_contains(r.writer(), "Model");
}

#[tokio::test]
async fn test_settings_overlay_open_close_after_overflow_keeps_prompt_and_layout_valid() {
    let config_options = make_settings_options();
    let terminal = TestTerminal::new(80, 8);
    let mut r = Renderer::new(terminal, TEST_AGENT.to_string(), &config_options, (80, 8));
    r.initial_render().unwrap();

    for i in 0..50 {
        r.on_session_update(acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
            acp::TextContent::new(format!("Line {i:02} with enough content to wrap in 40 cols")),
        ))))
        .unwrap();
    }

    type_string(&mut r, "/settings").await;
    press_enter(&mut r).await;
    assert!(has_settings_menu(r.writer()));
    assert_buffer_contains(r.writer(), "Configuration");

    press_esc(&mut r).await;
    assert!(!has_settings_menu(r.writer()));

    let lines = r.writer().get_lines();
    assert!(lines.iter().any(|l| l.chars().all(|c| c == '') && !l.is_empty()), "Prompt rule should be visible");
    assert!(lines.iter().any(|l| !l.trim().is_empty()), "Frame should not be empty");
}

#[tokio::test]
async fn test_settings_option_update_refreshes_mode_display() {
    let initial = vec![
        acp::SessionConfigOption::select(
            "mode",
            "Mode",
            "planner",
            vec![
                acp::SessionConfigSelectOption::new("planner", "Planner"),
                acp::SessionConfigSelectOption::new("coder", "Coder"),
            ],
        )
        .category(acp::SessionConfigOptionCategory::Mode),
    ];
    let terminal = TestTerminal::new(TEST_WIDTH, 40);
    let mut r = Renderer::new(terminal, TEST_AGENT.to_string(), &initial, (TEST_WIDTH, 40));
    r.initial_render().unwrap();

    let updated = vec![
        acp::SessionConfigOption::select(
            "mode",
            "Mode",
            "coder",
            vec![
                acp::SessionConfigSelectOption::new("planner", "Planner"),
                acp::SessionConfigSelectOption::new("coder", "Coder"),
            ],
        )
        .category(acp::SessionConfigOptionCategory::Mode),
    ];
    r.on_session_update(acp::SessionUpdate::ConfigOptionUpdate(acp::ConfigOptionUpdate::new(updated))).unwrap();
    assert_buffer_contains(r.writer(), "Coder");
}

#[tokio::test]
async fn test_server_status_notification_updates_overlay_state() {
    let mut r = open_settings(&[], (TEST_WIDTH, 40)).await;
    let notification = acp::ExtNotification::from(acp_utils::notifications::McpNotification::ServerStatus {
        servers: vec![acp_utils::notifications::McpServerStatusEntry {
            name: "docs".to_string(),
            status: acp_utils::notifications::McpServerStatus::Connected { tool_count: 0 },
        }],
    });
    r.on_ext_notification(notification).unwrap();
    assert!(has_settings_menu(r.writer()));
}