magi-code 0.96.1

Repository-aware CLI coding agent for terminal work
Documentation
use super::*;

#[derive(Clone, Copy)]
pub(super) enum BoundaryBias {
    Backward,
    Forward,
}
#[derive(Clone, Copy)]
pub(super) enum DeleteOperation {
    BackwardChar,
    ForwardChar,
    BackwardWord,
    ForwardWord,
}

impl DeleteOperation {
    pub(super) fn atomic_direction(self) -> tui_textarea::AtomicDeleteDirection {
        match self {
            Self::BackwardChar | Self::BackwardWord => {
                tui_textarea::AtomicDeleteDirection::Backward
            }
            Self::ForwardChar | Self::ForwardWord => tui_textarea::AtomicDeleteDirection::Forward,
        }
    }

    pub(super) fn apply(self, textarea: &mut TextArea<'_>) -> bool {
        match self {
            Self::BackwardChar => textarea.delete_char(),
            Self::ForwardChar => textarea.delete_next_char(),
            Self::BackwardWord => textarea.delete_word(),
            Self::ForwardWord => textarea.delete_next_word(),
        }
    }
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum WordCharKind {
    Space,
    Punct,
    Other,
}

impl WordCharKind {
    pub(super) fn new(ch: char) -> Self {
        if ch.is_whitespace() {
            Self::Space
        } else if ch == '_' {
            Self::Other
        } else if ch.is_ascii_punctuation() {
            Self::Punct
        } else {
            Self::Other
        }
    }
}

// Mirror tui-textarea's deletion semantics from the pre-edit cursor. Using the
// range rather than comparing before/after text keeps identical labels distinct.
fn ordinary_deletion_range(
    visible: &str,
    cursor: usize,
    operation: DeleteOperation,
) -> Option<(usize, usize)> {
    let cursor = clamp_char_boundary(visible, cursor);
    match operation {
        DeleteOperation::BackwardChar | DeleteOperation::ForwardChar => {
            if matches!(operation, DeleteOperation::BackwardChar) {
                visible[..cursor]
                    .char_indices()
                    .next_back()
                    .map(|(start, _)| (start, cursor))
            } else {
                visible[cursor..]
                    .chars()
                    .next()
                    .map(|ch| (cursor, cursor.saturating_add(ch.len_utf8())))
            }
        }
        DeleteOperation::BackwardWord | DeleteOperation::ForwardWord => {
            let line_start = visible[..cursor]
                .rfind('\n')
                .map_or(0, |newline| newline.saturating_add(1));
            let line_end = visible[cursor..]
                .find('\n')
                .map_or(visible.len(), |newline| cursor.saturating_add(newline));
            let line = &visible[line_start..line_end];
            let column = visible[line_start..cursor].chars().count();

            match operation {
                DeleteOperation::BackwardWord => {
                    if column > 0 {
                        let start_column = find_word_start_backward(line, column).unwrap_or(0);
                        Some((
                            line_start
                                .saturating_add(byte_offset_for_char_column(line, start_column)),
                            cursor,
                        ))
                    } else if line_start > 0 {
                        Some((line_start - 1, cursor))
                    } else {
                        None
                    }
                }
                DeleteOperation::ForwardWord => {
                    let line_char_count = line.chars().count();
                    if column < line_char_count {
                        let end_column = find_word_exclusive_end_forward(line, column)
                            .unwrap_or(line_char_count);
                        Some((
                            cursor,
                            line_start
                                .saturating_add(byte_offset_for_char_column(line, end_column)),
                        ))
                    } else if line_end < visible.len() {
                        Some((cursor, line_end + 1))
                    } else {
                        None
                    }
                }
                DeleteOperation::BackwardChar | DeleteOperation::ForwardChar => unreachable!(),
            }
        }
    }
}

fn find_word_start_backward(line: &str, start_column: usize) -> Option<usize> {
    let byte = byte_offset_for_char_column(line, start_column);
    let mut chars = line[..byte].chars().rev().enumerate();
    let mut current = WordCharKind::new(chars.next()?.1);
    for (index, ch) in chars {
        let next = WordCharKind::new(ch);
        if current != WordCharKind::Space && next != current {
            return Some(start_column - index);
        }
        current = next;
    }
    (current != WordCharKind::Space).then_some(0)
}

fn find_word_exclusive_end_forward(line: &str, start_column: usize) -> Option<usize> {
    let mut chars = line.chars().enumerate().skip(start_column);
    let mut previous = WordCharKind::new(chars.next()?.1);
    for (column, ch) in chars {
        let current = WordCharKind::new(ch);
        if previous != WordCharKind::Space && previous != current {
            return Some(column);
        }
        previous = current;
    }
    None
}

impl PromptEditor {
    pub(super) fn replace_active_selection(
        &mut self,
        visible_replacement: &str,
        expanded_replacement: &str,
        atom: Option<PasteAtom>,
    ) -> PromptEditResult {
        let (start, end) = self.selection_visible_bounds().unwrap_or_else(|| {
            let cursor = self.visible_cursor();
            (cursor, cursor)
        });
        let selected_len = self.expanded_visible_range(start, end).len();
        if visible_replacement.is_empty() && selected_len == 0 {
            return PromptEditResult::Unchanged;
        }
        let Some(current_len) = self.checked_expanded_text_len() else {
            return PromptEditResult::RejectedTooLarge;
        };
        if !replacement_fits(current_len, selected_len, expanded_replacement.len()) {
            return PromptEditResult::RejectedTooLarge;
        }

        let semantic_history_before = (!self.semantic_history_active && atom.is_some())
            .then(|| self.semantic_history.clone());
        if atom.is_some() {
            self.activate_semantic_history();
        }
        self.prepare_semantic_edit();
        let modified = self.textarea.borrow_mut().insert_str(visible_replacement);
        if !modified {
            if let Some(semantic_history) = semantic_history_before {
                self.semantic_history = semantic_history;
                self.semantic_history_active = false;
            }
            return PromptEditResult::Unchanged;
        }

        let new_end = start.saturating_add(visible_replacement.len());
        self.update_atoms_for_visible_replacement(start, end, start, new_end, atom);
        self.finish_content_edit();
        PromptEditResult::Changed
    }

    pub(super) fn edit(&mut self, operation: DeleteOperation) -> PromptEditResult {
        self.prepare_semantic_edit();
        let Some((old_start, old_end)) = self.deletion_range(operation) else {
            return PromptEditResult::Unchanged;
        };
        let modified = operation.apply(&mut self.textarea.borrow_mut());
        if modified {
            self.update_atoms_for_visible_replacement(
                old_start, old_end, old_start, old_start, None,
            );
            self.finish_content_edit();
            PromptEditResult::Changed
        } else {
            PromptEditResult::Unchanged
        }
    }

    pub(super) fn deletion_range(&self, operation: DeleteOperation) -> Option<(usize, usize)> {
        if let Some((start, end)) = self.selection_byte_bounds()
            && start < end
        {
            return Some(self.expanded_visible_bounds(start, end));
        }

        let atomic_range = {
            let textarea = self.textarea.borrow();
            textarea
                .atomic_range_at_cursor(operation.atomic_direction())
                .map(|range| {
                    (
                        byte_offset_for_cursor(textarea.lines(), (range.row, range.start_col)),
                        byte_offset_for_cursor(textarea.lines(), (range.row, range.end_col)),
                    )
                })
        };
        if let Some(range) = atomic_range {
            return Some(range);
        }

        let visible = self.visible_text();
        let cursor = self.visible_cursor();
        let (start, end) = ordinary_deletion_range(&visible, cursor, operation)?;
        Some(self.expanded_visible_bounds(start, end))
    }

    pub(super) fn edit_without_atom_tracking<F>(&mut self, operation: F) -> PromptEditResult
    where
        F: FnOnce(&mut TextArea<'static>) -> bool,
    {
        let modified = operation(&mut self.textarea.borrow_mut());
        if modified {
            self.bump_content_revision();
            self.pending_submission = None;
            self.cursor_visible = true;
            PromptEditResult::Changed
        } else {
            PromptEditResult::Unchanged
        }
    }
}