aether-wisp 0.4.35

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use std::fs::{create_dir_all, read_to_string, write};
use std::path::Path;
use std::process::Command;

use tui::{KeyCode, KeyModifiers};

use super::common::*;

fn run_git(dir: &Path, args: &[&str]) -> String {
    let output = Command::new("git").args(args).current_dir(dir).output().unwrap();
    assert!(output.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&output.stderr));
    String::from_utf8_lossy(&output.stdout).into_owned()
}

fn init_temp_repo() -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path();
    run_git(path, &["init", "--quiet"]);
    run_git(path, &["config", "user.email", "test@example.com"]);
    run_git(path, &["config", "user.name", "Test"]);
    run_git(path, &["config", "commit.gpgsign", "false"]);
    dir
}

async fn open_git_diff(renderer: &mut Renderer) -> TestResult {
    press_with_modifiers(renderer, KeyCode::Char('g'), KeyModifiers::CONTROL).await
}

#[tokio::test]
async fn git_diff_scope_cycles_between_both_unstaged_and_staged() -> TestResult {
    let repo = init_temp_repo();
    let dir = repo.path().to_path_buf();
    write(dir.join("a.txt"), "one\n").unwrap();
    run_git(&dir, &["add", "-A"]);
    run_git(&dir, &["commit", "--quiet", "-m", "init"]);
    write(dir.join("a.txt"), "two\n").unwrap();
    run_git(&dir, &["add", "a.txt"]);
    write(dir.join("a.txt"), "three\n").unwrap();
    write(dir.join("untracked.txt"), "scratch\n").unwrap();

    let mut renderer = RendererTest::new().size((TEST_WIDTH, 40)).working_dir(dir).build()?;
    open_git_diff(&mut renderer).await?;
    assert_buffer_contains(renderer.writer(), "Git Diff · Both");
    assert_buffer_contains(renderer.writer(), "untracked.txt");

    press(&mut renderer, KeyCode::Char('t')).await?;
    assert_buffer_contains(renderer.writer(), "Git Diff · Unstaged");
    assert_buffer_contains(renderer.writer(), "untracked.txt");

    press(&mut renderer, KeyCode::Char('t')).await?;
    assert_buffer_contains(renderer.writer(), "Git Diff · Staged");
    assert_buffer_not_contains(renderer.writer(), "untracked.txt");
    Ok(())
}

#[tokio::test]
async fn typing_long_commit_message_keeps_diff_rows_visible() -> TestResult {
    let repo = init_temp_repo();
    let dir = repo.path().to_path_buf();
    write(dir.join("a.txt"), "one\n").unwrap();
    run_git(&dir, &["add", "-A"]);
    run_git(&dir, &["commit", "--quiet", "-m", "init"]);
    write(dir.join("a.txt"), "one\ntwo\n").unwrap();

    let mut renderer = RendererTest::new().size((50, 12)).working_dir(dir.clone()).build()?;
    open_git_diff(&mut renderer).await?;
    press(&mut renderer, KeyCode::Char(' ')).await?;
    press(&mut renderer, KeyCode::Char('C')).await?;
    type_string(&mut renderer, "this is a fairly long commit message that should not break layout").await?;

    assert_buffer_contains(renderer.writer(), "Git Diff");
    assert_buffer_contains(renderer.writer(), "break layout");
    Ok(())
}

#[tokio::test]
async fn typing_short_chars_in_commit_box_does_not_scroll_tall_diff() -> TestResult {
    let repo = init_temp_repo();
    let dir = repo.path().to_path_buf();
    let initial = (0..40).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n") + "\n";
    write(dir.join("a.txt"), &initial).unwrap();
    run_git(&dir, &["add", "-A"]);
    run_git(&dir, &["commit", "--quiet", "-m", "init"]);
    let modified = (0..40).map(|i| format!("line {i} edited")).collect::<Vec<_>>().join("\n") + "\n";
    write(dir.join("a.txt"), &modified).unwrap();

    let mut renderer = RendererTest::new().size((80, 10)).working_dir(dir.clone()).build()?;
    open_git_diff(&mut renderer).await?;
    press(&mut renderer, KeyCode::Char(' ')).await?;
    press(&mut renderer, KeyCode::Char('C')).await?;

    let rows_before = renderer.writer().get_lines().iter().filter(|l| !l.trim().is_empty()).count();
    type_string(&mut renderer, "hello").await?;
    let rows_after = renderer.writer().get_lines().iter().filter(|l| !l.trim().is_empty()).count();

    assert_buffer_contains(renderer.writer(), "Git Diff");
    assert_eq!(rows_before, rows_after, "typing into the commit box must not change the number of visible rows");
    Ok(())
}

#[tokio::test]
async fn space_stages_selected_file() -> TestResult {
    let repo = init_temp_repo();
    let dir = repo.path().to_path_buf();
    write(dir.join("a.txt"), "one\n").unwrap();
    run_git(&dir, &["add", "-A"]);
    run_git(&dir, &["commit", "--quiet", "-m", "init"]);
    write(dir.join("a.txt"), "one\ntwo\n").unwrap();

    let mut renderer = RendererTest::new().size((TEST_WIDTH, 40)).working_dir(dir.clone()).build()?;
    open_git_diff(&mut renderer).await?;
    assert_buffer_contains(renderer.writer(), "");

    press(&mut renderer, KeyCode::Char(' ')).await?;

    assert_buffer_contains(renderer.writer(), "");
    assert!(run_git(&dir, &["status", "--porcelain"]).contains("M  a.txt"), "a.txt should be staged");
    Ok(())
}

#[tokio::test]
async fn space_toggles_staging_for_selected_directory() -> TestResult {
    let repo = init_temp_repo();
    let dir = repo.path().to_path_buf();
    create_dir_all(dir.join("src/nested")).unwrap();
    write(dir.join("src/a.txt"), "one\n").unwrap();
    write(dir.join("src/b.txt"), "one\n").unwrap();
    write(dir.join("src/nested/c.txt"), "one\n").unwrap();
    run_git(&dir, &["add", "-A"]);
    run_git(&dir, &["commit", "--quiet", "-m", "init"]);
    write(dir.join("src/a.txt"), "two\n").unwrap();
    write(dir.join("src/b.txt"), "two\n").unwrap();
    write(dir.join("src/nested/c.txt"), "two\n").unwrap();

    let mut renderer = RendererTest::new().size((TEST_WIDTH, 40)).working_dir(dir.clone()).build()?;
    open_git_diff(&mut renderer).await?;

    assert!(
        renderer.writer().get_lines().iter().any(|line| {
            let line = line.trim_end();
            line.contains("src/") && line.contains("")
        }),
        "directory should show its unstaged checkbox"
    );

    press(&mut renderer, KeyCode::Char(' ')).await?;

    assert!(
        renderer.writer().get_lines().iter().any(|line| {
            let line = line.trim_end();
            line.contains("src/") && line.contains("")
        }),
        "directory should show its staged checkbox"
    );
    assert_eq!(run_git(&dir, &["status", "--porcelain"]), "M  src/a.txt\nM  src/b.txt\nM  src/nested/c.txt\n");

    press(&mut renderer, KeyCode::Char(' ')).await?;

    assert_eq!(run_git(&dir, &["status", "--porcelain"]), " M src/a.txt\n M src/b.txt\n M src/nested/c.txt\n");
    Ok(())
}

#[tokio::test]
async fn commit_via_composer_creates_commit_and_clears_changes() -> TestResult {
    let repo = init_temp_repo();
    let dir = repo.path().to_path_buf();
    write(dir.join("a.txt"), "one\n").unwrap();
    run_git(&dir, &["add", "-A"]);
    run_git(&dir, &["commit", "--quiet", "-m", "init"]);
    write(dir.join("a.txt"), "one\ntwo\n").unwrap();

    let mut renderer = RendererTest::new().size((TEST_WIDTH, 40)).working_dir(dir.clone()).build()?;
    open_git_diff(&mut renderer).await?;
    press(&mut renderer, KeyCode::Char(' ')).await?;
    press(&mut renderer, KeyCode::Char('C')).await?;
    type_string(&mut renderer, "second commit").await?;
    assert_buffer_contains(renderer.writer(), "second commit");

    press(&mut renderer, Enter).await?;

    assert!(run_git(&dir, &["log", "--oneline"]).contains("second commit"), "commit should be created");
    assert_buffer_contains(renderer.writer(), "No changes");
    Ok(())
}

#[tokio::test]
async fn commit_with_nothing_staged_shows_hint() -> TestResult {
    let repo = init_temp_repo();
    let dir = repo.path().to_path_buf();
    write(dir.join("a.txt"), "one\n").unwrap();
    run_git(&dir, &["add", "-A"]);
    run_git(&dir, &["commit", "--quiet", "-m", "init"]);
    write(dir.join("a.txt"), "one\ntwo\n").unwrap();

    let mut renderer = RendererTest::new().size((TEST_WIDTH, 40)).working_dir(dir.clone()).build()?;
    open_git_diff(&mut renderer).await?;

    press(&mut renderer, KeyCode::Char('C')).await?;

    assert_buffer_contains(renderer.writer(), "Nothing staged");
    Ok(())
}

#[tokio::test]
async fn discard_confirm_reverts_file() -> TestResult {
    let repo = init_temp_repo();
    let dir = repo.path().to_path_buf();
    write(dir.join("a.txt"), "v1\n").unwrap();
    run_git(&dir, &["add", "-A"]);
    run_git(&dir, &["commit", "--quiet", "-m", "init"]);
    write(dir.join("a.txt"), "v2\n").unwrap();

    let mut renderer = RendererTest::new().size((TEST_WIDTH, 40)).working_dir(dir.clone()).build()?;
    open_git_diff(&mut renderer).await?;

    press(&mut renderer, KeyCode::Char('d')).await?;
    assert_buffer_contains(renderer.writer(), "Discard");

    press(&mut renderer, KeyCode::Char('y')).await?;

    assert_eq!(read_to_string(dir.join("a.txt")).unwrap(), "v1\n", "file should be reverted");
    assert_buffer_contains(renderer.writer(), "No changes");
    Ok(())
}

#[tokio::test]
async fn ctrl_g_toggles_git_diff_and_mouse_capture() -> TestResult {
    let mut renderer = RendererTest::new().size((TEST_WIDTH, 40)).build()?;

    assert!(!renderer.needs_mouse_capture());

    press_with_modifiers(&mut renderer, KeyCode::Char('g'), KeyModifiers::CONTROL).await?;
    assert!(renderer.needs_mouse_capture(), "git diff mode should capture mouse input");

    press_with_modifiers(&mut renderer, KeyCode::Char('g'), KeyModifiers::CONTROL).await?;
    assert!(!renderer.needs_mouse_capture(), "closing git diff should release mouse capture");
    Ok(())
}

#[tokio::test]
async fn ctrl_g_is_ignored_while_modal_is_open() -> TestResult {
    let mut renderer = open_settings(&make_settings_options(), (TEST_WIDTH, 40)).await?;
    assert!(has_settings_menu(renderer.writer()), "settings menu should be visible");

    press_with_modifiers(&mut renderer, KeyCode::Char('g'), KeyModifiers::CONTROL).await?;

    assert!(has_settings_menu(renderer.writer()), "settings menu should remain visible");
    assert!(renderer.needs_mouse_capture(), "modal should continue capturing mouse input");
    Ok(())
}

#[tokio::test]
async fn esc_in_git_diff_does_not_cancel_waiting_prompt() -> TestResult {
    let mut renderer = RendererTest::new().size((TEST_WIDTH, 40)).build()?;

    type_string(&mut renderer, "hello").await?;
    press(&mut renderer, Enter).await?;
    assert_buffer_contains(renderer.writer(), "esc to interrupt");

    press_with_modifiers(&mut renderer, KeyCode::Char('g'), KeyModifiers::CONTROL).await?;
    assert!(renderer.needs_mouse_capture(), "git diff should be active");

    press(&mut renderer, Esc).await?;

    assert!(!renderer.needs_mouse_capture(), "Esc in git diff should close diff mode");
    assert_buffer_contains(renderer.writer(), "esc to interrupt");
    Ok(())
}