aether-wisp 0.1.5

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use crate::components::app::git_diff_mode::{PatchLineRef, QueuedComment};
use std::collections::HashMap;
use tui::{Color, Line, Style, Theme};

pub struct CommentBlock {
    pub anchor: PatchLineRef,
    pub rows: Vec<Line>,
    pub content_row_offset: usize,
}

#[derive(Clone)]
pub struct DraftCommentState {
    pub anchor: PatchLineRef,
    pub text: String,
    pub cursor_position: usize,
}

pub struct DraftCommentBlock {
    pub block: CommentBlock,
    pub cursor_row_offset: usize,
    pub cursor_col: usize,
}

pub fn render_comment_blocks(comments: &[&QueuedComment], width: usize, theme: &Theme) -> Vec<CommentBlock> {
    let mut grouped: Vec<(PatchLineRef, Vec<&QueuedComment>)> = Vec::new();
    let mut index_by_anchor: HashMap<PatchLineRef, usize> = HashMap::new();

    for comment in comments {
        if let Some(index) = index_by_anchor.get(&comment.patch_ref).copied() {
            grouped[index].1.push(comment);
        } else {
            let index = grouped.len();
            grouped.push((comment.patch_ref, vec![comment]));
            index_by_anchor.insert(comment.patch_ref, index);
        }
    }

    grouped
        .into_iter()
        .map(|(anchor, grouped_comments)| render_grouped_comment_block(anchor, &grouped_comments, width, theme))
        .collect()
}

pub fn render_comment_block(anchor: PatchLineRef, text: &str, width: usize, theme: &Theme) -> CommentBlock {
    let (rows, content_row_offset) = render_comment_box_rows(text, width, theme);
    CommentBlock { anchor, rows, content_row_offset }
}

pub fn render_draft_comment_block(draft: &DraftCommentState, width: usize, theme: &Theme) -> DraftCommentBlock {
    let text = if draft.text.is_empty() { " " } else { &draft.text };
    let (rows, content_row_offset) = render_comment_box_rows(text, width, theme);

    let inner_width = width.saturating_sub(draft_text_col_start());
    let (cursor_row, cursor_col) = cursor_row_col(text, draft.cursor_position, inner_width);

    DraftCommentBlock {
        block: CommentBlock { anchor: draft.anchor, rows, content_row_offset },
        cursor_row_offset: content_row_offset + cursor_row,
        cursor_col: draft_text_col_start() + cursor_col,
    }
}

fn render_grouped_comment_block(
    anchor: PatchLineRef,
    comments: &[&QueuedComment],
    width: usize,
    theme: &Theme,
) -> CommentBlock {
    let mut rows = Vec::new();
    let mut content_row_offset = 1;

    for (index, comment) in comments.iter().enumerate() {
        let comment_block = render_comment_block(anchor, &comment.comment, width, theme);
        if index == 0 {
            content_row_offset = comment_block.content_row_offset;
        }
        rows.extend(comment_block.rows);
    }

    CommentBlock { anchor, rows, content_row_offset }
}

fn render_comment_box_rows(text: &str, width: usize, theme: &Theme) -> (Vec<Line>, usize) {
    let indent = 2;
    let box_left = "";
    let bg = theme.sidebar_bg();
    let border_fg = theme.muted();
    let text_fg = theme.text_primary();

    let dashes = width.saturating_sub(indent + 1);
    let inner_width = width.saturating_sub(indent + box_left.len() + 1);
    let wrapped = wrap_text(text, inner_width);

    let mut rows = Vec::new();
    push_border_row(&mut rows, "", indent, dashes, width, border_fg, bg);

    for text_line in &wrapped {
        let mut row = Line::default();
        row.push_with_style(" ".repeat(indent), Style::default().bg_color(bg));
        row.push_with_style(box_left, Style::fg(border_fg).bg_color(bg));
        row.push_with_style(text_line.as_str(), Style::fg(text_fg).bg_color(bg));
        row.extend_bg_to_width(width);
        rows.push(row);
    }

    push_border_row(&mut rows, "", indent, dashes, width, border_fg, bg);
    (rows, 1)
}

fn push_border_row(
    rows: &mut Vec<Line>,
    corner: &str,
    indent: usize,
    dashes: usize,
    width: usize,
    border_fg: Color,
    bg: Color,
) {
    let mut row = Line::default();
    row.push_with_style(" ".repeat(indent), Style::default().bg_color(bg));
    row.push_with_style(corner, Style::fg(border_fg).bg_color(bg));
    row.push_with_style("".repeat(dashes), Style::fg(border_fg).bg_color(bg));
    row.extend_bg_to_width(width);
    rows.push(row);
}

fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
    if max_width == 0 {
        return vec![String::new()];
    }

    let mut lines = Vec::new();
    let mut current = String::new();
    let mut current_len = 0usize;

    for word in text.split_whitespace() {
        let word_len = word.chars().count();
        if current_len == 0 {
            current.push_str(word);
            current_len = word_len;
        } else if current_len + 1 + word_len <= max_width {
            current.push(' ');
            current.push_str(word);
            current_len += 1 + word_len;
        } else {
            lines.push(std::mem::take(&mut current));
            current.push_str(word);
            current_len = word_len;
        }
    }

    if !current.is_empty() || lines.is_empty() {
        lines.push(current);
    }

    lines
}

fn cursor_row_col(text: &str, cursor_position: usize, max_width: usize) -> (usize, usize) {
    if max_width == 0 {
        return (0, 0);
    }

    let canonical = text.split_whitespace().collect::<Vec<_>>().join(" ");
    let wrapped = wrap_text(if canonical.is_empty() { " " } else { &canonical }, max_width);
    let cursor = cursor_position.min(canonical.chars().count());

    let mut consumed = 0usize;
    for (row_idx, line) in wrapped.iter().enumerate() {
        let line_len = line.chars().count();
        if cursor <= consumed + line_len {
            return (row_idx, cursor.saturating_sub(consumed));
        }
        consumed += line_len + 1;
    }

    wrapped.last().map_or((0, 0), |last| (wrapped.len().saturating_sub(1), last.chars().count()))
}

fn draft_text_col_start() -> usize {
    2 + 2
}

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

    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: crate::git_diff::PatchLineKind::Added,
            comment: comment.to_string(),
        }
    }

    #[test]
    fn wrap_text_basic() {
        let result = wrap_text("hello world foo bar", 10);
        assert_eq!(result, vec!["hello", "world foo", "bar"]);
    }

    #[test]
    fn render_comment_blocks_preserves_anchor_order() {
        let anchor = PatchLineRef { hunk_index: 0, line_index: 1 };
        let first = queued(anchor, "alpha");
        let second = queued(anchor, "beta");
        let refs = vec![&first, &second];
        let theme = tui::ViewContext::new((80, 24)).theme;

        let blocks = render_comment_blocks(&refs, 60, &theme);
        assert_eq!(blocks.len(), 1);
        assert!(blocks[0].rows.iter().any(|row| row.plain_text().contains("alpha")));
        assert!(blocks[0].rows.iter().any(|row| row.plain_text().contains("beta")));
    }

    #[test]
    fn draft_block_has_borders_and_cursor() {
        let draft = DraftCommentState {
            anchor: PatchLineRef { hunk_index: 0, line_index: 1 },
            text: "test comment".to_string(),
            cursor_position: 4,
        };
        let theme = tui::ViewContext::new((80, 24)).theme;
        let rendered = render_draft_comment_block(&draft, 60, &theme);

        assert!(rendered.block.rows.len() >= 3);
        assert!(rendered.block.rows[0].plain_text().contains(''));
        assert!(rendered.block.rows.last().is_some_and(|row| row.plain_text().contains('')));
        assert!(rendered.cursor_col >= draft_text_col_start());
    }
}