aether-wisp 0.2.0

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use super::comment_box::{CommentBox, DRAFT_TEXT_COL_START, wrap_text};
use super::{AnchoredRows, CommentAnchor, FrameSplice};
use std::hash::Hash;
use tui::{Component, Cursor, Event, Frame, KeyCode, ViewContext};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DraftComment<A> {
    pub anchor: CommentAnchor<A>,
    pub text: String,
    pub cursor_position: usize,
}

pub(crate) enum DraftCommentEdit<A> {
    Noop,
    Cancelled,
    Submitted { anchor: CommentAnchor<A>, text: String },
}

impl<A: Copy> DraftComment<A> {
    pub(crate) fn new(anchor: CommentAnchor<A>) -> Self {
        Self { anchor, text: String::new(), cursor_position: 0 }
    }
}

impl<A: Copy> Component for DraftComment<A> {
    type Message = DraftCommentEdit<A>;

    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
        let Event::Key(key) = event else {
            return None;
        };

        let edit = match key.code {
            KeyCode::Esc => DraftCommentEdit::Cancelled,
            KeyCode::Enter => {
                if self.text.trim().is_empty() {
                    DraftCommentEdit::Cancelled
                } else {
                    DraftCommentEdit::Submitted { anchor: self.anchor, text: self.text.clone() }
                }
            }
            KeyCode::Char(ch) => {
                let byte_pos = char_to_byte_pos(&self.text, self.cursor_position);
                self.text.insert(byte_pos, ch);
                self.cursor_position += 1;
                DraftCommentEdit::Noop
            }
            KeyCode::Backspace => {
                if self.cursor_position > 0 {
                    self.cursor_position -= 1;
                    let byte_pos = char_to_byte_pos(&self.text, self.cursor_position);
                    self.text.remove(byte_pos);
                }
                DraftCommentEdit::Noop
            }
            KeyCode::Left => {
                self.cursor_position = self.cursor_position.saturating_sub(1);
                DraftCommentEdit::Noop
            }
            KeyCode::Right => {
                let max = self.text.chars().count();
                self.cursor_position = (self.cursor_position + 1).min(max);
                DraftCommentEdit::Noop
            }
            _ => DraftCommentEdit::Noop,
        };

        match edit {
            DraftCommentEdit::Noop => Some(vec![]),
            edit => Some(vec![edit]),
        }
    }

    fn render(&mut self, ctx: &ViewContext) -> Frame {
        let text = if self.text.is_empty() { " " } else { &self.text };
        let lines = CommentBox { text }.render(ctx).into_lines();
        let inner_width = usize::from(ctx.size.width).saturating_sub(DRAFT_TEXT_COL_START);
        let (cursor_row, cursor_col_offset) = cursor_row_col(text, self.cursor_position, inner_width);
        let cursor_row_offset = 1 + cursor_row;
        let cursor_col = (DRAFT_TEXT_COL_START + cursor_col_offset).min(usize::from(ctx.size.width).saturating_sub(1));

        Frame::new(lines).with_cursor(Cursor::visible(cursor_row_offset, cursor_col))
    }
}

impl<A: Copy + Eq + Hash> DraftComment<A> {
    pub(crate) fn splice_into(&self, surface: &AnchoredRows<A>, ctx: &ViewContext) -> Option<FrameSplice> {
        let end_row = surface.end_row_for_anchor(self.anchor)?;

        let mut draft = self.clone();
        Some(FrameSplice { after_row: end_row, frame: draft.render(ctx) })
    }
}

fn char_to_byte_pos(text: &str, char_idx: usize) -> usize {
    text.char_indices().nth(char_idx).map_or(text.len(), |(index, _)| index)
}

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()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tui::{KeyEvent, KeyModifiers, Line};

    fn key_event(code: KeyCode) -> Event {
        Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
    }

    #[tokio::test]
    async fn submitting_empty_text_cancels_draft() {
        let mut draft = DraftComment::new(CommentAnchor(3));

        let outcome = draft.on_event(&key_event(KeyCode::Enter)).await.unwrap();
        assert!(matches!(outcome.as_slice(), [DraftCommentEdit::Cancelled]));
    }

    #[tokio::test]
    async fn inserting_and_submitting_returns_comment() {
        let mut draft = DraftComment::new(CommentAnchor(5));
        draft.on_event(&key_event(KeyCode::Char('h'))).await;
        draft.on_event(&key_event(KeyCode::Char('i'))).await;

        let outcome = draft.on_event(&key_event(KeyCode::Enter)).await.unwrap();
        assert!(matches!(outcome.as_slice(), [DraftCommentEdit::Submitted { .. }]));
    }

    #[test]
    fn component_render_has_borders_and_cursor() {
        let mut draft = DraftComment { anchor: CommentAnchor(2), text: "test comment".to_string(), cursor_position: 4 };
        let ctx = ViewContext::new((60, 24));
        let rendered = draft.render(&ctx);

        assert!(rendered.lines().len() >= 3);
        assert!(rendered.lines()[0].plain_text().contains(''));
        assert!(rendered.lines().last().is_some_and(|row| row.plain_text().contains('')));
        assert!(rendered.cursor().col >= DRAFT_TEXT_COL_START);
    }

    #[test]
    fn splice_into_uses_anchor_end_row() {
        let mut rows: AnchoredRows<usize> = AnchoredRows::default();
        rows.push_anchored_rows(CommentAnchor(4), [Line::new("row0")]);
        let draft = DraftComment { anchor: CommentAnchor(4), text: "hello".to_string(), cursor_position: 5 };
        let ctx = ViewContext::new((40, 24));

        let splice = draft.splice_into(&rows, &ctx).expect("draft should splice into anchored rows");

        assert_eq!(splice.after_row, 0);
    }

    #[test]
    fn splice_into_returns_none_for_unknown_anchor() {
        let mut rows: AnchoredRows<usize> = AnchoredRows::default();
        rows.push_unanchored_rows([Line::new("row0")]);
        let draft = DraftComment { anchor: CommentAnchor(4), text: "hello".to_string(), cursor_position: 5 };
        let ctx = ViewContext::new((40, 24));

        assert!(draft.splice_into(&rows, &ctx).is_none());
    }
}