aether-wisp 0.1.7

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use tui::Renderer;
use tui::testing::TestTerminal;
use tui::{Cursor, Frame, Line, Theme};

fn render_frame(renderer: &mut Renderer<TestTerminal>, lines: Vec<Line>, cursor: Cursor) {
    renderer.render_frame(|_ctx| Frame::new(lines).with_cursor(cursor)).unwrap();
}

#[test]
fn render_soft_wraps_before_diffing() {
    let mut renderer = create_renderer(3, 20);

    render_frame(&mut renderer, vec![Line::new("abcdef")], Cursor { row: 0, col: 5, is_visible: true });

    let lines = renderer.writer().get_lines();
    assert_eq!(lines[0], "abc");
    assert_eq!(lines[1], "def");
}

#[test]
fn push_to_scrollback_soft_wraps_long_lines() {
    let mut renderer = create_renderer(5, 20);

    render_frame(&mut renderer, vec![Line::new("abcde")], Cursor { row: 0, col: 0, is_visible: true });

    renderer.push_to_scrollback(&[Line::new("0123456789")]).unwrap();

    let transcript = renderer.writer().get_transcript_lines();
    assert!(
        transcript.iter().any(|l| l.contains("01234")),
        "expected wrapped first half in transcript: {transcript:?}"
    );
    assert!(
        transcript.iter().any(|l| l.contains("56789")),
        "expected wrapped second half in transcript: {transcript:?}"
    );
    assert!(
        !transcript.iter().any(|l| l.contains("0123456789")),
        "line should have been split by soft-wrap: {transcript:?}"
    );
}

#[test]
fn out_of_bounds_cursor_clamps_without_panicking() {
    let mut renderer = create_renderer(4, 20);

    render_frame(&mut renderer, vec![Line::new("a")], Cursor { row: 10, col: 100, is_visible: true });

    let lines = renderer.writer().get_lines();
    assert_eq!(lines[0], "a");
}

#[test]
fn render_flushes_overflow_to_scrollback() {
    let mut renderer = create_renderer(20, 3);

    render_frame(
        &mut renderer,
        vec![Line::new("L1"), Line::new("L2"), Line::new("L3"), Line::new("L4"), Line::new("L5")],
        Cursor { row: 4, col: 0, is_visible: true },
    );

    let visible = renderer.writer().get_lines();
    assert_eq!(visible[0], "L3");
    assert_eq!(visible[1], "L4");
    assert_eq!(visible[2], "L5");

    let transcript = renderer.writer().get_transcript_lines();
    assert!(transcript.iter().any(|l| l == "L1"), "L1 should be in scrollback: {transcript:?}");
    assert!(transcript.iter().any(|l| l == "L2"), "L2 should be in scrollback: {transcript:?}");
}

#[test]
fn render_progressively_flushes_overflow() {
    let mut renderer = create_renderer(20, 3);

    render_frame(
        &mut renderer,
        vec![Line::new("L1"), Line::new("L2"), Line::new("L3"), Line::new("L4")],
        Cursor { row: 3, col: 0, is_visible: true },
    );

    let transcript_after_first = renderer.writer().get_transcript_lines();
    assert!(
        transcript_after_first.iter().any(|l| l == "L1"),
        "L1 should be flushed after first render: {transcript_after_first:?}"
    );

    render_frame(
        &mut renderer,
        vec![Line::new("L1"), Line::new("L2"), Line::new("L3"), Line::new("L4"), Line::new("L5"), Line::new("L6")],
        Cursor { row: 5, col: 0, is_visible: true },
    );

    let transcript_after_second = renderer.writer().get_transcript_lines();
    assert!(
        transcript_after_second.iter().any(|l| l == "L2"),
        "L2 should be in transcript after second render: {transcript_after_second:?}"
    );
    assert!(
        transcript_after_second.iter().any(|l| l == "L3"),
        "L3 should be in transcript after second render: {transcript_after_second:?}"
    );

    let visible = renderer.writer().get_lines();
    assert_eq!(visible[0], "L4");
    assert_eq!(visible[1], "L5");
    assert_eq!(visible[2], "L6");
}

#[test]
fn push_to_scrollback_resets_flushed_count() {
    let mut renderer = create_renderer(20, 3);

    render_frame(
        &mut renderer,
        vec![Line::new("L1"), Line::new("L2"), Line::new("L3"), Line::new("L4"), Line::new("L5")],
        Cursor { row: 4, col: 0, is_visible: true },
    );

    renderer.push_to_scrollback(&[Line::new("committed")]).unwrap();

    render_frame(
        &mut renderer,
        vec![Line::new("A1"), Line::new("A2"), Line::new("A3"), Line::new("A4"), Line::new("A5")],
        Cursor { row: 4, col: 0, is_visible: true },
    );

    let transcript = renderer.writer().get_transcript_lines();
    assert!(
        transcript.iter().any(|l| l == "A1"),
        "A1 should be in scrollback (proves counter was reset): {transcript:?}"
    );
    assert!(
        transcript.iter().any(|l| l == "A2"),
        "A2 should be in scrollback (proves counter was reset): {transcript:?}"
    );
}

fn create_renderer(cols: u16, rows: u16) -> Renderer<TestTerminal> {
    let terminal = TestTerminal::new(cols, rows);
    Renderer::new(terminal, Theme::default(), (cols, rows))
}