aether-wisp 0.2.0

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use super::AnchoredRows;
use crate::components::common::VerticalCursor;
use std::collections::HashMap;
use std::hash::Hash;
use tui::{Frame, Line, Style, Theme};

#[derive(Clone)]
pub(crate) struct FrameSplice {
    pub after_row: usize,
    pub frame: Frame,
}

pub(crate) fn compose_review_surface<A: Copy + Eq + Hash>(
    rows: &AnchoredRows<A>,
    cursor: &mut VerticalCursor,
    comment_splices: &[FrameSplice],
    draft_splice: Option<&FrameSplice>,
    theme: &Theme,
    body_height: usize,
) -> Frame {
    let lines = rows.lines();
    let lines_len = lines.len();
    let cursor_row = cursor.row;
    let draft_after_row = draft_splice.map(|d| d.after_row);

    let mut splices_by_row: HashMap<usize, Vec<&FrameSplice>> = HashMap::new();
    let mut trailing_splices: Vec<&FrameSplice> = Vec::new();
    let mut submitted_total = 0usize;
    let mut cursor_offset = 0usize;
    let mut submitted_up_to_draft = 0usize;

    for splice in comment_splices {
        let height = splice.frame.lines().len();
        submitted_total += height;

        if splice.after_row < cursor_row {
            cursor_offset += height;
        }
        if let Some(draft_row) = draft_after_row
            && splice.after_row <= draft_row
        {
            submitted_up_to_draft += height;
        }

        if splice.after_row >= lines_len {
            trailing_splices.push(splice);
        } else {
            splices_by_row.entry(splice.after_row).or_default().push(splice);
        }
    }

    let draft_total = draft_splice.map_or(0, |s| s.frame.lines().len());
    let total_height = lines_len + submitted_total + draft_total;

    if let Some(draft) = draft_splice
        && draft.after_row < cursor_row
    {
        cursor_offset += draft.frame.lines().len();
    }
    let cursor_visual_row = cursor_row + cursor_offset;

    let max_scroll = total_height.saturating_sub(body_height);
    cursor.scroll = cursor.scroll.min(max_scroll);
    cursor.ensure_visible(cursor_visual_row, body_height);

    if body_height > 0
        && let Some(first_at_cursor) = splices_by_row.get(&cursor_row).and_then(|v| v.first())
    {
        let comment_end = cursor_visual_row + first_at_cursor.frame.lines().len();
        if comment_end >= cursor.scroll + body_height {
            cursor.scroll = comment_end.saturating_sub(body_height - 1).min(cursor_visual_row);
        }
    }

    if let Some(draft) = draft_splice {
        let draft_end = draft.after_row + submitted_up_to_draft + draft.frame.lines().len();
        cursor.ensure_visible(draft_end, body_height);
    }
    cursor.scroll = cursor.scroll.min(max_scroll);

    let viewport_start = cursor.scroll;
    let viewport_end = viewport_start.saturating_add(body_height);
    let mut output = Vec::with_capacity(body_height);
    let mut visual_row = 0usize;

    let emit_line = |output: &mut Vec<Line>, visual_row: usize, line: &Line, highlight: bool| {
        if visual_row < viewport_start || visual_row >= viewport_end {
            return;
        }
        output.push(if highlight { apply_cursor_highlight(line, theme) } else { line.clone() });
    };

    for (src_idx, src_line) in lines.iter().enumerate() {
        emit_line(&mut output, visual_row, src_line, src_idx == cursor_row);
        visual_row += 1;

        if let Some(row_splices) = splices_by_row.get(&src_idx) {
            for splice in row_splices {
                for line in splice.frame.lines() {
                    emit_line(&mut output, visual_row, line, false);
                    visual_row += 1;
                }
            }
        }

        if let Some(draft) = draft_splice
            && draft.after_row == src_idx
        {
            for line in draft.frame.lines() {
                emit_line(&mut output, visual_row, line, false);
                visual_row += 1;
            }
        }

        if visual_row >= viewport_end {
            break;
        }
    }

    for splice in &trailing_splices {
        for line in splice.frame.lines() {
            emit_line(&mut output, visual_row, line, false);
            visual_row += 1;
        }
    }
    if let Some(draft) = draft_splice
        && draft.after_row >= lines_len
    {
        for line in draft.frame.lines() {
            emit_line(&mut output, visual_row, line, false);
            visual_row += 1;
        }
    }

    Frame::new(output)
}

fn apply_cursor_highlight(line: &Line, theme: &Theme) -> Line {
    let highlight_bg = theme.highlight_bg();
    let mut highlighted = Line::default();

    for span in line.spans() {
        highlighted.push_with_style(span.text(), span.style().bg_color(highlight_bg));
    }

    if line.is_empty() {
        highlighted.push_with_style(" ", Style::default().bg_color(highlight_bg));
    }

    highlighted
}

#[cfg(test)]
mod tests {
    use super::super::CommentAnchor;
    use super::*;

    fn make_rows(count: usize) -> AnchoredRows<usize> {
        let mut rows: AnchoredRows<usize> = AnchoredRows::default();
        for i in 0..count {
            rows.push_anchored_rows(CommentAnchor(i), [Line::new(format!("row{i}"))]);
        }
        rows
    }

    fn splice(after_row: usize, height: usize) -> FrameSplice {
        let lines: Vec<Line> = (0..height).map(|i| Line::new(format!("s{after_row}.{i}"))).collect();
        FrameSplice { after_row, frame: Frame::new(lines) }
    }

    fn line_texts(frame: &Frame) -> Vec<String> {
        frame.lines().iter().map(|line| line.spans().iter().map(|s| s.text().to_string()).collect()).collect()
    }

    #[test]
    fn emits_source_rows_and_splices_in_order_for_large_input() {
        let rows = make_rows(50);
        let splices: Vec<FrameSplice> = (0..50).step_by(5).map(|r| splice(r, 2)).collect();
        let mut cursor = VerticalCursor { row: 0, scroll: 0 };
        let theme = Theme::default();
        let body_height = 200;

        let frame = compose_review_surface(&rows, &mut cursor, &splices, None, &theme, body_height);

        let texts = line_texts(&frame);
        assert_eq!(texts.len(), 50 + splices.len() * 2);
        assert_eq!(texts[0], "row0");
        assert_eq!(texts[1], "s0.0");
        assert_eq!(texts[2], "s0.1");
        assert_eq!(texts[3], "row1");
    }

    #[test]
    fn cursor_highlight_applies_to_cursor_row() {
        let rows = make_rows(4);
        let mut cursor = VerticalCursor { row: 2, scroll: 0 };
        let theme = Theme::default();

        let frame = compose_review_surface(&rows, &mut cursor, &[], None, &theme, 10);

        assert_eq!(frame.lines().len(), 4);
    }

    #[test]
    fn trailing_splice_renders_after_all_source_rows() {
        let rows = make_rows(3);
        let splices = vec![splice(100, 1)];
        let mut cursor = VerticalCursor { row: 0, scroll: 0 };
        let theme = Theme::default();

        let frame = compose_review_surface(&rows, &mut cursor, &splices, None, &theme, 20);

        let texts = line_texts(&frame);
        assert_eq!(texts, vec!["row0", "row1", "row2", "s100.0"]);
    }

    #[test]
    fn draft_after_last_row_renders_at_end() {
        let rows = make_rows(2);
        let draft = splice(5, 2);
        let mut cursor = VerticalCursor { row: 0, scroll: 0 };
        let theme = Theme::default();

        let frame = compose_review_surface(&rows, &mut cursor, &[], Some(&draft), &theme, 20);

        let texts = line_texts(&frame);
        assert_eq!(texts, vec!["row0", "row1", "s5.0", "s5.1"]);
    }
}