aether-wisp 0.1.5

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use super::git_diff_comment_renderer::{self, DraftCommentState};
use super::patch_renderer::RenderedPatch;
use super::split_patch_renderer::build_split_patch_base_lines;
use crate::components::app::git_diff_mode::QueuedComment;
use crate::git_diff::FileDiff;
use tui::{Cursor, Frame, Line, Theme, ViewContext};

pub struct FrameSplice {
    pub after_row: usize,
    pub frame: Frame,
}

pub struct DraftSplice {
    pub splice: FrameSplice,
}

#[derive(Clone, PartialEq, Eq)]
struct DiffLayerKey {
    document_revision: usize,
    width: u16,
    file_path: String,
    layout: DiffLayout,
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum DiffLayout {
    Unified,
    Split,
}

pub struct CachedDiffLayer {
    key: Option<DiffLayerKey>,
    rendered: Option<RenderedPatch>,
}

impl CachedDiffLayer {
    fn new() -> Self {
        Self { key: None, rendered: None }
    }

    fn invalidate(&mut self) {
        self.key = None;
        self.rendered = None;
    }

    fn ensure(&mut self, file: &FileDiff, width: u16, split_layout: bool, document_revision: usize, ctx: &ViewContext) {
        let key = DiffLayerKey {
            document_revision,
            width,
            file_path: file.path.clone(),
            layout: if split_layout { DiffLayout::Split } else { DiffLayout::Unified },
        };

        if self.key.as_ref() == Some(&key) && self.rendered.is_some() {
            return;
        }

        self.rendered = Some(if split_layout {
            build_split_patch_base_lines(file, usize::from(width), ctx)
        } else {
            RenderedPatch::from_file_diff(file, usize::from(width), ctx)
        });
        self.key = Some(key);
    }
}

#[derive(Clone, PartialEq, Eq)]
struct CommentLayerKey {
    revision: usize,
    width: u16,
    file_path: String,
}

struct CachedCommentLayer {
    key: Option<CommentLayerKey>,
    splices: Vec<FrameSplice>,
}

impl CachedCommentLayer {
    fn new() -> Self {
        Self { key: None, splices: Vec::new() }
    }

    fn invalidate(&mut self) {
        self.key = None;
        self.splices.clear();
    }

    fn ensure(
        &mut self,
        file: &FileDiff,
        comments: &[&QueuedComment],
        width: u16,
        revision: usize,
        rendered: &RenderedPatch,
        theme: &Theme,
    ) {
        let key = CommentLayerKey { revision, width, file_path: file.path.clone() };
        if self.key.as_ref() == Some(&key) {
            return;
        }

        let diff_line_count = rendered.lines.len();
        let mut splices: Vec<FrameSplice> = Vec::new();

        for block in git_diff_comment_renderer::render_comment_blocks(comments, usize::from(width), theme) {
            let Some(insertion_row) = rendered.line_ref_to_anchor_row_index.get(&block.anchor).copied() else {
                continue;
            };
            if insertion_row == 0 || insertion_row > diff_line_count {
                continue;
            }

            let after_row = insertion_row - 1;
            if let Some(last) = splices.last_mut()
                && last.after_row == after_row
            {
                let mut lines = last.frame.lines().to_vec();
                lines.extend(block.rows);
                last.frame = Frame::new(lines);
            } else {
                splices.push(FrameSplice { after_row, frame: Frame::new(block.rows) });
            }
        }

        self.key = Some(key);
        self.splices = splices;
    }
}

pub struct GitDiffCompositor {
    diff_layer: CachedDiffLayer,
    submitted_layer: CachedCommentLayer,
    submitted_revision: usize,
}

impl GitDiffCompositor {
    pub fn new() -> Self {
        Self { diff_layer: CachedDiffLayer::new(), submitted_layer: CachedCommentLayer::new(), submitted_revision: 0 }
    }

    pub fn invalidate_diff_layer(&mut self) {
        self.diff_layer.invalidate();
    }

    pub fn invalidate_submitted_comments_layer(&mut self) {
        self.submitted_revision = self.submitted_revision.saturating_add(1);
        self.submitted_layer.invalidate();
    }

    pub fn invalidate_all(&mut self) {
        self.diff_layer.invalidate();
        self.invalidate_submitted_comments_layer();
    }

    pub fn ensure_diff_layer(
        &mut self,
        file: &FileDiff,
        width: u16,
        split_layout: bool,
        document_revision: usize,
        ctx: &ViewContext,
    ) {
        self.diff_layer.ensure(file, width, split_layout, document_revision, ctx);
    }

    pub fn ensure_submitted_layer(&mut self, file: &FileDiff, comments: &[&QueuedComment], width: u16, theme: &Theme) {
        let Some(rendered) = self.diff_layer.rendered.as_ref() else {
            self.submitted_layer.invalidate();
            return;
        };

        self.submitted_layer.ensure(file, comments, width, self.submitted_revision, rendered, theme);
    }

    pub fn rendered_patch(&self) -> Option<&RenderedPatch> {
        self.diff_layer.rendered.as_ref()
    }

    pub fn comment_splices(&self) -> &[FrameSplice] {
        &self.submitted_layer.splices
    }

    pub fn draft_splice(&self, draft: &DraftCommentState, width: usize, theme: &Theme) -> Option<DraftSplice> {
        let rendered = self.diff_layer.rendered.as_ref()?;
        let insertion_row = rendered.line_ref_to_anchor_row_index.get(&draft.anchor).copied()?;
        let block = git_diff_comment_renderer::render_draft_comment_block(draft, width, theme);

        let cursor_row = block.cursor_row_offset;
        let cursor_col = block.cursor_col.min(width.saturating_sub(1));
        let frame = Frame::new(block.block.rows).with_cursor(Cursor::visible(cursor_row, cursor_col));

        Some(DraftSplice { splice: FrameSplice { after_row: insertion_row - 1, frame } })
    }
}

impl Default for GitDiffCompositor {
    fn default() -> Self {
        Self::new()
    }
}

pub 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(" ", tui::Style::default().bg_color(highlight_bg));
    }

    highlighted
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::components::app::git_diff_mode::PatchLineRef;
    use crate::git_diff::{FileStatus, Hunk, PatchLine, PatchLineKind};

    fn context() -> ViewContext {
        ViewContext::new((120, 24))
    }

    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,
        }
    }

    fn queued(anchor: PatchLineRef, comment: &str) -> QueuedComment {
        QueuedComment {
            file_path: "test.rs".to_string(),
            patch_ref: anchor,
            line_text: "line".to_string(),
            line_number: Some(1),
            line_kind: PatchLineKind::Added,
            comment: comment.to_string(),
        }
    }

    fn initialize_layers(
        compositor: &mut GitDiffCompositor,
        file: &FileDiff,
        width: u16,
        comments: &[QueuedComment],
        document_revision: usize,
    ) {
        let ctx = context();
        let refs = comments.iter().collect::<Vec<_>>();
        compositor.ensure_diff_layer(file, width, false, document_revision, &ctx);
        compositor.ensure_submitted_layer(file, &refs, width, &ctx.theme);
    }

    #[test]
    fn compositor_starts_empty() {
        let compositor = GitDiffCompositor::new();
        assert!(compositor.rendered_patch().is_none());
        assert!(compositor.comment_splices().is_empty());
    }

    #[test]
    fn comment_splices_preserve_input_order_for_same_anchor() {
        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: "new_line();".to_string(),
                old_line_no: None,
                new_line_no: Some(1),
            },
        ]);

        let anchor = PatchLineRef { hunk_index: 0, line_index: 1 };
        let comments = vec![queued(anchor, "alpha"), queued(anchor, "beta")];

        let mut compositor = GitDiffCompositor::new();
        initialize_layers(&mut compositor, &file, 80, &comments, 1);

        let splices = compositor.comment_splices();
        assert_eq!(splices.len(), 1);
        let rendered_text: Vec<String> = splices[0].frame.lines().iter().map(Line::plain_text).collect();
        let alpha_pos = rendered_text.iter().position(|t| t.contains("alpha")).expect("alpha should render");
        let beta_pos = rendered_text.iter().position(|t| t.contains("beta")).expect("beta should render");
        assert!(alpha_pos < beta_pos, "comments should render in queue order");
    }

    #[test]
    fn comment_splice_uses_correct_after_row() {
        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: "new_line();".to_string(),
                old_line_no: None,
                new_line_no: Some(1),
            },
        ]);

        let anchor = PatchLineRef { hunk_index: 0, line_index: 1 };
        let comments = vec![queued(anchor, "a comment")];

        let mut compositor = GitDiffCompositor::new();
        initialize_layers(&mut compositor, &file, 80, &comments, 1);

        let splices = compositor.comment_splices();
        assert_eq!(splices.len(), 1);

        let rendered = compositor.rendered_patch().unwrap();
        let insertion_row = rendered.line_ref_to_anchor_row_index[&anchor];
        assert_eq!(splices[0].after_row, insertion_row - 1);
    }

    #[test]
    fn draft_splice_positions_cursor() {
        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: "new_line();".to_string(),
                old_line_no: None,
                new_line_no: Some(1),
            },
        ]);

        let mut compositor = GitDiffCompositor::new();
        initialize_layers(&mut compositor, &file, 40, &[], 1);

        let draft = DraftCommentState {
            anchor: PatchLineRef { hunk_index: 0, line_index: 1 },
            text: "hello".to_string(),
            cursor_position: 5,
        };

        let splice = compositor.draft_splice(&draft, 40, &context().theme).expect("draft splice should exist");
        assert!(splice.splice.frame.cursor().is_visible);
        assert!(splice.splice.frame.lines().len() >= 3);
    }

    #[test]
    fn draft_splice_returns_none_for_unknown_anchor() {
        let file = make_file(vec![PatchLine {
            kind: PatchLineKind::HunkHeader,
            text: "@@ -1,1 +1,1 @@".to_string(),
            old_line_no: None,
            new_line_no: None,
        }]);

        let mut compositor = GitDiffCompositor::new();
        initialize_layers(&mut compositor, &file, 40, &[], 1);

        let draft = DraftCommentState {
            anchor: PatchLineRef { hunk_index: 99, line_index: 99 },
            text: "hello".to_string(),
            cursor_position: 0,
        };

        assert!(compositor.draft_splice(&draft, 40, &context().theme).is_none());
    }
}