aether-wisp 0.3.1

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use super::PatchAnchor;
use crate::components::common::AnchoredSurfaceBuilder;
use crate::components::review_comments::{AnchoredBlock, AnchoredRows, CommentAnchor};
use crate::git_diff::{FileDiff, PatchLineKind};
use tui::{Line, ViewContext};

pub(crate) struct RenderedPatch {
    pub surface: AnchoredRows<PatchAnchor>,
    pub hunk_offsets: Vec<usize>,
}

impl RenderedPatch {
    pub(crate) fn new(surface: AnchoredRows<PatchAnchor>) -> Self {
        let hunk_offsets = compute_hunk_offsets(surface.blocks());
        Self { surface, hunk_offsets }
    }

    pub(crate) fn from_file_diff(file: &FileDiff, width: usize, ctx: &ViewContext) -> RenderedPatch {
        let theme = &ctx.theme;
        let lang_hint = lang_hint_from_path(&file.path);
        let max_line_no = file
            .hunks
            .iter()
            .flat_map(|hunk| &hunk.lines)
            .filter_map(|line| line.old_line_no.into_iter().chain(line.new_line_no).max())
            .max()
            .unwrap_or(0);

        let gutter_width = tui::digit_count(max_line_no);
        let width_u16 = usize_to_u16_saturating(width);
        let mut rows = AnchoredSurfaceBuilder::new();

        for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
            if hunk_idx > 0 {
                rows.push_raw_unanchored_rows([Line::default()]);
            }

            for (line_idx, patch_line) in hunk.lines.iter().enumerate() {
                let (head, tail, content) = match patch_line.kind {
                    PatchLineKind::HunkHeader => (
                        Line::default(),
                        Line::default(),
                        Line::with_style(
                            &patch_line.text,
                            tui::Style::fg(theme.info()).bold().bg_color(theme.code_bg()),
                        ),
                    ),
                    PatchLineKind::Meta => (
                        Line::default(),
                        Line::default(),
                        Line::with_style(&patch_line.text, tui::Style::fg(theme.text_secondary()).italic()),
                    ),
                    PatchLineKind::Context => {
                        let old_str = format_line_no(patch_line.old_line_no, gutter_width);
                        let new_str = format_line_no(patch_line.new_line_no, gutter_width);
                        let head =
                            Line::with_style(format!("{old_str} {new_str}   "), tui::Style::fg(theme.text_secondary()));
                        let tail = Line::new(" ".repeat(2 * gutter_width + 4));
                        let mut content = Line::default();
                        append_syntax_spans(&mut content, &patch_line.text, lang_hint, None, ctx);
                        (head, tail, content)
                    }
                    PatchLineKind::Added => {
                        let old_str = " ".repeat(gutter_width);
                        let new_str = format_line_no(patch_line.new_line_no, gutter_width);
                        let bg = theme.diff_added_bg();
                        let head = Line::with_style(
                            format!("{old_str} {new_str} + "),
                            tui::Style::fg(theme.diff_added_fg()).bg_color(bg),
                        );
                        let tail =
                            Line::with_style(" ".repeat(2 * gutter_width + 4), tui::Style::default().bg_color(bg));
                        let mut content = Line::default();
                        append_syntax_spans(&mut content, &patch_line.text, lang_hint, Some(bg), ctx);
                        let content = content.with_fill(bg);
                        (head, tail, content)
                    }
                    PatchLineKind::Removed => {
                        let old_str = format_line_no(patch_line.old_line_no, gutter_width);
                        let new_str = " ".repeat(gutter_width);
                        let bg = theme.diff_removed_bg();
                        let head = Line::with_style(
                            format!("{old_str} {new_str} - "),
                            tui::Style::fg(theme.diff_removed_fg()).bg_color(bg),
                        );
                        let tail =
                            Line::with_style(" ".repeat(2 * gutter_width + 4), tui::Style::default().bg_color(bg));
                        let mut content = Line::default();
                        append_syntax_spans(&mut content, &patch_line.text, lang_hint, Some(bg), ctx);
                        let content = content.with_fill(bg);
                        (head, tail, content)
                    }
                };

                let anchor = CommentAnchor(PatchAnchor { hunk: hunk_idx, line: line_idx });
                rows.push_anchored_wrapped(anchor, content, width_u16, &head, &tail);
            }
        }

        Self::new(rows.finish())
    }
}

fn append_syntax_spans(
    line: &mut Line,
    text: &str,
    lang_hint: &str,
    bg_override: Option<tui::Color>,
    ctx: &ViewContext,
) {
    let spans = ctx.highlighter().highlight(text, lang_hint, &ctx.theme);
    if let Some(content) = spans.first() {
        for span in content.spans() {
            let mut span_style = span.style();
            if let Some(bg) = bg_override {
                span_style.bg = Some(bg);
            }
            line.push_span(tui::Span::with_style(span.text(), span_style));
        }
    } else {
        line.push_text(text);
    }
}

fn format_line_no(line_no: Option<usize>, width: usize) -> String {
    match line_no {
        Some(line_no) => format!("{line_no:>width$}"),
        None => " ".repeat(width),
    }
}

pub(crate) fn lang_hint_from_path(path: &str) -> &str {
    path.rsplit('.').next().unwrap_or("")
}

pub(crate) fn usize_to_u16_saturating(value: usize) -> u16 {
    u16::try_from(value).unwrap_or(u16::MAX)
}

fn compute_hunk_offsets(blocks: &[AnchoredBlock<PatchAnchor>]) -> Vec<usize> {
    let mut offsets = Vec::new();
    let mut last_hunk: Option<usize> = None;

    for block in blocks {
        let CommentAnchor(PatchAnchor { hunk, .. }) = block.anchor;
        if last_hunk != Some(hunk) {
            offsets.push(block.start_row);
            last_hunk = Some(hunk);
        }
    }

    offsets
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git_diff::{FileDiff, FileStatus, Hunk, PatchLine};

    fn make_file(lines: Vec<PatchLine>) -> FileDiff {
        FileDiff {
            old_path: Some("test.rs".to_string()),
            path: "test.rs".to_string(),
            status: FileStatus::Modified,
            hunks: vec![Hunk {
                header: "@@ -1,1 +1,1 @@".to_string(),
                old_start: 1,
                old_count: 1,
                new_start: 1,
                new_count: 1,
                lines,
            }],
            binary: false,
        }
    }

    #[test]
    fn rendered_patch_contains_insert_row_lookup() {
        let file = make_file(vec![
            PatchLine {
                kind: PatchLineKind::HunkHeader,
                text: "@@ -1,1 +1,1 @@".to_string(),
                old_line_no: None,
                new_line_no: None,
            },
            PatchLine {
                kind: PatchLineKind::Context,
                text: "fn test()".to_string(),
                old_line_no: Some(1),
                new_line_no: Some(1),
            },
        ]);
        let context = ViewContext::new((120, 24));
        let result = RenderedPatch::from_file_diff(&file, 80, &context);

        assert_eq!(result.surface.blocks().len(), 2);
        assert_eq!(result.surface.end_row_for_anchor(CommentAnchor(PatchAnchor { hunk: 0, line: 0 })), Some(0));
    }

    #[test]
    fn rendered_patch_contains_hunk_offsets() {
        let file = make_file(vec![
            PatchLine {
                kind: PatchLineKind::HunkHeader,
                text: "@@ -1,1 +1,1 @@".to_string(),
                old_line_no: None,
                new_line_no: None,
            },
            PatchLine {
                kind: PatchLineKind::Context,
                text: "fn test()".to_string(),
                old_line_no: Some(1),
                new_line_no: Some(1),
            },
        ]);
        let context = ViewContext::new((120, 24));
        let result = RenderedPatch::from_file_diff(&file, 80, &context);

        assert!(!result.hunk_offsets.is_empty(), "should have at least one hunk offset");
        assert_eq!(result.hunk_offsets[0], 0, "first hunk should start at row 0");
    }

    #[test]
    fn long_lines_soft_wrapped_to_right_width() {
        let long_content = "x".repeat(200);
        let file = make_file(vec![
            PatchLine {
                kind: PatchLineKind::HunkHeader,
                text: "@@ -1,1 +1,1 @@".to_string(),
                old_line_no: None,
                new_line_no: None,
            },
            PatchLine { kind: PatchLineKind::Added, text: long_content, old_line_no: None, new_line_no: Some(1) },
        ]);
        let context = ViewContext::new((120, 24));
        let right_width = 60;
        let result = RenderedPatch::from_file_diff(&file, right_width, &context);

        assert!(result.surface.lines().len() > 2, "long line should wrap, got {} lines", result.surface.lines().len());

        for (index, line) in result.surface.lines().iter().enumerate() {
            let display_width = line.display_width();
            assert!(
                display_width <= right_width,
                "line {index} width {display_width} exceeds right_width {right_width}"
            );
        }

        let anchor = CommentAnchor(PatchAnchor { hunk: 0, line: 1 });
        assert_eq!(result.surface.start_row_for_anchor(anchor), Some(1));
        assert!(
            result.surface.end_row_for_anchor(anchor).is_some_and(|end_row| end_row > 1),
            "wrapped line should extend the anchored block"
        );

        let gutter_cols = 2 * tui::digit_count(1) + 4;
        let gutter_pad = " ".repeat(gutter_cols);
        for (index, line) in result.surface.lines().iter().enumerate().skip(2) {
            let text = line.plain_text();
            assert!(
                text.starts_with(&gutter_pad),
                "continuation line {index} should start with blank gutter, got {text:?}"
            );
        }
    }

    #[test]
    fn added_row_continuation_preserves_added_background() {
        let long_content = "x".repeat(200);
        let file = make_file(vec![
            PatchLine {
                kind: PatchLineKind::HunkHeader,
                text: "@@ -1,1 +1,1 @@".to_string(),
                old_line_no: None,
                new_line_no: None,
            },
            PatchLine { kind: PatchLineKind::Added, text: long_content, old_line_no: None, new_line_no: Some(1) },
        ]);
        let context = ViewContext::new((120, 24));
        let added_bg = context.theme.diff_added_bg();
        let result = RenderedPatch::from_file_diff(&file, 60, &context);

        assert!(result.surface.lines().len() > 2, "long Added line should wrap");

        for (index, line) in result.surface.lines().iter().enumerate().skip(2) {
            let first_span = line.spans().first().expect("continuation row should have spans");
            assert_eq!(
                first_span.style().bg,
                Some(added_bg),
                "continuation line {index}: gutter tail should carry diff_added_bg, got {:?}",
                first_span.style().bg
            );
        }
    }

    #[test]
    fn empty_added_line_fills_full_width_with_added_background() {
        let file = make_file(vec![
            PatchLine {
                kind: PatchLineKind::HunkHeader,
                text: "@@ -1,1 +1,1 @@".to_string(),
                old_line_no: None,
                new_line_no: None,
            },
            PatchLine { kind: PatchLineKind::Added, text: String::new(), old_line_no: None, new_line_no: Some(1) },
        ]);
        let context = ViewContext::new((120, 24));
        let added_bg = context.theme.diff_added_bg();
        let total_width = 60;
        let result = RenderedPatch::from_file_diff(&file, total_width, &context);

        let added_row = &result.surface.lines()[1];
        assert_eq!(
            added_row.display_width(),
            total_width,
            "empty added row should be padded to full width, got {}",
            added_row.display_width()
        );
        let trailing = added_row.spans().last().expect("added row should have at least one span");
        assert_eq!(
            trailing.style().bg,
            Some(added_bg),
            "trailing pad of empty added line should carry diff_added_bg, got {:?}",
            trailing.style().bg
        );
    }

    #[test]
    fn lang_hint_extracts_extension() {
        assert_eq!(lang_hint_from_path("src/main.rs"), "rs");
        assert_eq!(lang_hint_from_path("foo.py"), "py");
        assert_eq!(lang_hint_from_path("Makefile"), "Makefile");
        assert_eq!(lang_hint_from_path("a/b/c.tsx"), "tsx");
    }

    #[test]
    fn usize_to_u16_saturating_clamps_large_values() {
        assert_eq!(usize_to_u16_saturating(123), 123);
        assert_eq!(usize_to_u16_saturating(usize::from(u16::MAX)), u16::MAX);
        assert_eq!(usize_to_u16_saturating(usize::from(u16::MAX) + 1), u16::MAX);
    }
}