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::DraftCommentState;
use crate::components::app::git_diff_mode::PatchLineRef;
use tui::KeyCode;

pub struct DraftCommentEditor {
    draft: Option<DraftCommentState>,
}

pub enum DraftCommentEdit {
    Noop,
    Cancelled,
    Submitted { anchor: PatchLineRef, text: String },
}

impl DraftCommentEditor {
    pub fn new() -> Self {
        Self { draft: None }
    }

    pub fn is_active(&self) -> bool {
        self.draft.is_some()
    }

    pub fn begin(&mut self, anchor: PatchLineRef) {
        self.draft = Some(DraftCommentState { anchor, text: String::new(), cursor_position: 0 });
    }

    pub fn cancel(&mut self) {
        self.draft = None;
    }

    pub fn state(&self) -> Option<DraftCommentState> {
        self.draft.clone()
    }

    pub fn handle_key(&mut self, code: KeyCode) -> DraftCommentEdit {
        let Some(draft) = self.draft.as_mut() else {
            return DraftCommentEdit::Noop;
        };

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

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

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

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

    #[test]
    fn submitting_empty_text_cancels_draft() {
        let anchor = PatchLineRef { hunk_index: 0, line_index: 1 };
        let mut editor = DraftCommentEditor::new();
        editor.begin(anchor);

        assert!(matches!(editor.handle_key(KeyCode::Enter), DraftCommentEdit::Cancelled));
        assert!(!editor.is_active());
    }

    #[test]
    fn inserting_and_submitting_returns_comment() {
        let anchor = PatchLineRef { hunk_index: 0, line_index: 1 };
        let mut editor = DraftCommentEditor::new();
        editor.begin(anchor);
        editor.handle_key(KeyCode::Char('h'));
        editor.handle_key(KeyCode::Char('i'));

        let submitted = editor.handle_key(KeyCode::Enter);
        assert!(matches!(submitted, DraftCommentEdit::Submitted { .. }));
    }
}