travelagent 1.11.1

Agent-first TUI code review tool
//! Command-palette and command-mode input state, extracted from
//! `App` and `UiLayoutState` in v1.6 (Phase H continuation).
//!
//! The v1.5 architect/critic/code-reviewer pass flagged split
//! ownership: `command_buffer` lived on `App`, `palette_cursor`
//! lived on `UiLayoutState`, and every handler in
//! `handler::handle_command_palette_action` paired them in a
//! "reset cursor when buffer changes" dance. This struct unifies
//! both so the invariant lives with the data. Same shape as
//! `UiLayoutState`'s comment-template-picker encapsulation
//! (PR #188) — private fields, methods named after user intent,
//! cursor auto-reset on every buffer mutation.
//!
//! The `buffer` is shared between `InputMode::Command` (the `:`
//! prompt that submits a single command on Enter) and
//! `InputMode::CommandPalette` (the filterable popup). That's why
//! `palette_cursor` is private-via-method but the `buffer` accessor
//! exists — command-mode's submit handler needs to read and clear
//! the buffer without having a fake "cursor" to manage.

/// See module docs. The buffer is shared between Command and
/// CommandPalette modes; the cursor is only meaningful in
/// CommandPalette but lives here so the invariant (cursor resets on
/// every buffer mutation) can be enforced centrally.
#[derive(Debug, Clone, Default)]
pub struct PaletteState {
    /// Text typed after `:` (Command mode) or into the palette
    /// filter (CommandPalette mode). Private — mutate through the
    /// `push_/pop_/word_delete_/clear_*` methods so `cursor` stays
    /// reset.
    buffer: String,
    /// Highlighted row in the filtered palette list. Always reset
    /// to 0 on any buffer mutation. Private.
    cursor: usize,
}

impl PaletteState {
    /// Read-only view of the command/palette buffer.
    pub fn buffer(&self) -> &str {
        &self.buffer
    }

    /// Whether the buffer is empty. Equivalent to `self.buffer().is_empty()`
    /// but avoids the caller reaching for the full string.
    pub fn is_empty(&self) -> bool {
        self.buffer.is_empty()
    }

    /// Highlighted row in the filtered palette. Callers map this to
    /// a palette entry by re-running the filter themselves (see
    /// `ui::command_palette::filter_entries`).
    pub fn cursor(&self) -> usize {
        self.cursor
    }

    /// Append a character to the buffer and reset the cursor to 0.
    pub fn push_char(&mut self, c: char) {
        self.buffer.push(c);
        self.cursor = 0;
    }

    /// Hard cap on buffer length. The palette is a short command
    /// prompt, not a free-form editor; a paste larger than this is
    /// almost certainly an accident (e.g. user ctrl-V'd a code block
    /// into `:`) and unbounded growth would make the next
    /// `filter_entries` pass O(N) in the paste size.
    const MAX_BUFFER_BYTES: usize = 64 * 1024;

    /// Bulk append from a string (e.g. bracketed-paste) and reset the
    /// cursor to 0. Truncates at a UTF-8 boundary if the resulting
    /// buffer would exceed `MAX_BUFFER_BYTES`; returns `true` when
    /// truncation happened so the handler can surface a warning.
    pub fn push_str(&mut self, s: &str) -> bool {
        let available = Self::MAX_BUFFER_BYTES.saturating_sub(self.buffer.len());
        let to_take = available.min(s.len());
        // Walk back to the nearest UTF-8 char boundary so we never
        // split a multi-byte codepoint mid-sequence.
        let mut cut = to_take;
        while cut > 0 && !s.is_char_boundary(cut) {
            cut -= 1;
        }
        self.buffer.push_str(&s[..cut]);
        self.cursor = 0;
        cut < s.len()
    }

    /// Pop the last character and reset the cursor to 0. Returns
    /// the popped char (or `None` if the buffer was empty) so the
    /// command-palette handler can decide whether to exit the mode
    /// on an empty buffer.
    pub fn pop_char(&mut self) -> Option<char> {
        let popped = self.buffer.pop();
        self.cursor = 0;
        popped
    }

    /// Word-delete the buffer (trailing whitespace then trailing
    /// non-whitespace) and reset the cursor. Mirrors the
    /// `UiLayoutState::word_delete_template_filter` shape so Ctrl+W
    /// feels consistent across pickers.
    pub fn word_delete(&mut self) {
        while self.buffer.chars().last().is_some_and(char::is_whitespace) {
            self.buffer.pop();
        }
        while self
            .buffer
            .chars()
            .last()
            .is_some_and(|c| !c.is_whitespace())
        {
            self.buffer.pop();
        }
        self.cursor = 0;
    }

    /// Clear buffer + cursor. Called on mode exit and on palette
    /// entry dispatch.
    pub fn clear(&mut self) {
        self.buffer.clear();
        self.cursor = 0;
    }

    /// Move the cursor down by one, capped at the last valid row
    /// of a list with `filtered_len` entries. No-op when the list
    /// is empty.
    pub fn advance_cursor(&mut self, filtered_len: usize) {
        if let Some(max_idx) = filtered_len.checked_sub(1) {
            self.cursor = (self.cursor + 1).min(max_idx);
        }
    }

    /// Move the cursor up by one (saturating at 0).
    pub fn retreat_cursor(&mut self) {
        self.cursor = self.cursor.saturating_sub(1);
    }

    /// Overwrite the buffer with a literal string (e.g. the
    /// `execute_palette_entry` path that replays a palette key as
    /// an ad-hoc `:command`). Resets the cursor. Prefer the
    /// `push_char` path for normal typing.
    pub fn set_buffer(&mut self, s: String) {
        self.buffer = s;
        self.cursor = 0;
    }

    /// Consume the buffer, leaving it empty. Returns the prior
    /// contents so the caller (Command mode's `SubmitInput`) can
    /// trim and dispatch without a separate `clone + clear` dance.
    pub fn take_buffer(&mut self) -> String {
        let out = std::mem::take(&mut self.buffer);
        self.cursor = 0;
        out
    }
}

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

    #[test]
    fn push_resets_cursor_and_appends() {
        let mut p = PaletteState::default();
        p.advance_cursor(5);
        assert_ne!(p.cursor(), 0, "precondition: cursor seeded");
        p.push_char('h');
        assert_eq!(p.cursor(), 0);
        assert_eq!(p.buffer(), "h");
    }

    #[test]
    fn pop_returns_char_and_resets_cursor() {
        let mut p = PaletteState::default();
        p.push_char('a');
        p.push_char('b');
        p.advance_cursor(3);
        assert_ne!(p.cursor(), 0);
        let popped = p.pop_char();
        assert_eq!(popped, Some('b'));
        assert_eq!(p.buffer(), "a");
        assert_eq!(p.cursor(), 0);
    }

    #[test]
    fn pop_empty_returns_none() {
        let mut p = PaletteState::default();
        assert!(p.pop_char().is_none());
    }

    #[test]
    fn word_delete_peels_whitespace_then_word() {
        let mut p = PaletteState::default();
        "hello world  ".chars().for_each(|c| p.push_char(c));
        p.advance_cursor(4);
        p.word_delete();
        assert_eq!(p.buffer(), "hello ");
        assert_eq!(p.cursor(), 0);
    }

    #[test]
    fn clear_resets_buffer_and_cursor() {
        let mut p = PaletteState::default();
        p.push_char('x');
        p.advance_cursor(9);
        p.clear();
        assert_eq!(p.buffer(), "");
        assert_eq!(p.cursor(), 0);
    }

    #[test]
    fn advance_cursor_caps_at_max() {
        let mut p = PaletteState::default();
        for _ in 0..10 {
            p.advance_cursor(4);
        }
        assert_eq!(p.cursor(), 3);
    }

    #[test]
    fn advance_cursor_with_empty_list_is_noop() {
        let mut p = PaletteState::default();
        p.advance_cursor(0);
        assert_eq!(p.cursor(), 0);
    }

    #[test]
    fn retreat_saturates_at_zero() {
        let mut p = PaletteState::default();
        p.retreat_cursor();
        assert_eq!(p.cursor(), 0);
    }

    #[test]
    fn set_buffer_overwrites_and_resets_cursor() {
        let mut p = PaletteState::default();
        p.advance_cursor(7);
        p.set_buffer("diff".to_string());
        assert_eq!(p.buffer(), "diff");
        assert_eq!(p.cursor(), 0);
    }

    #[test]
    fn take_buffer_returns_prior_and_empties() {
        let mut p = PaletteState::default();
        p.push_char('q');
        p.push_char('!');
        let taken = p.take_buffer();
        assert_eq!(taken, "q!");
        assert_eq!(p.buffer(), "");
        assert_eq!(p.cursor(), 0);
    }
}