starweaver-cli 0.2.1

Command-line interface for Starweaver
Documentation
use super::{
    format_size_bytes, pasted_image_paths, previous_char_boundary, FooterMode, InteractiveTuiState,
    LocalCommandOutcome, PromptAttachment, PromptInput, SteeringSubmission, SubmissionKind,
};

fn is_composer_word_char(ch: char) -> bool {
    ch.is_alphanumeric() || ch == '_'
}

impl InteractiveTuiState {
    pub(in crate::tui) fn apply_paste(&mut self, text: &str) {
        self.footer_mode = FooterMode::Context;
        let image_paths = pasted_image_paths(text);
        if image_paths.is_empty() {
            self.insert_composer_str(text);
            self.input_status = Some(format!("pasted {} chars", text.chars().count()));
            return;
        }

        self.move_composer_cursor_to_end();
        if !self.input.is_empty() && !self.input.ends_with([' ', '\n']) {
            self.insert_composer_str(" ");
        }
        for path in image_paths {
            if !self.input.is_empty() && !self.input.ends_with([' ', '\n']) {
                self.insert_composer_str(" ");
            }
            self.insert_composer_str(&path);
        }
        self.input_status = Some("image path pasted".to_string());
    }

    pub(crate) fn attach_image(&mut self, attachment: PromptAttachment) {
        let placeholder = attachment.placeholder.clone();
        self.move_composer_cursor_to_end();
        if !self.input.is_empty() && !self.input.ends_with([' ', '\n']) {
            self.insert_composer_str(" ");
        }
        self.insert_composer_str(&placeholder);
        self.insert_composer_str(" ");
        self.pending_attachments.push(attachment);
        self.reset_composer_scroll();
        let count = self.pending_attachments.len();
        self.input_status = Some(if count == 1 {
            format!(
                "image attached: {}",
                self.pending_attachments[0].description()
            )
        } else {
            let total_size = self
                .pending_attachments
                .iter()
                .map(|attachment| attachment.size_bytes)
                .sum::<usize>();
            format!(
                "images attached: {count} ({})",
                format_size_bytes(total_size)
            )
        });
    }

    pub(in crate::tui) fn take_submission_prompt(&mut self) -> Option<PromptInput> {
        self.take_prompt(SubmissionKind::Message)
    }

    pub(in crate::tui) fn take_steering_prompt(&mut self) -> Option<SteeringSubmission> {
        self.retain_visible_attachments();
        if !self.pending_attachments.is_empty() {
            self.input_status = Some("image steering unsupported while running".to_string());
            return None;
        }
        self.take_prompt(SubmissionKind::Steering)
            .map(|input| self.record_steering_message(input.display_text()))
    }

    pub(crate) const fn take_pending_submission_display_prompt(&mut self) -> Option<String> {
        self.pending_submission_display_prompt.take()
    }

    pub(in crate::tui) fn take_paste_image_command(&mut self) -> bool {
        if self.input.trim() != "/paste-image" {
            return false;
        }
        self.input.clear();
        self.reset_composer_scroll();
        self.footer_mode = FooterMode::Context;
        true
    }

    fn take_prompt(&mut self, kind: SubmissionKind) -> Option<PromptInput> {
        self.retain_visible_attachments();
        let command = self.take_local_command();
        if matches!(
            command,
            LocalCommandOutcome::Consumed | LocalCommandOutcome::PasteImage
        ) {
            return None;
        }
        let prompt = match command {
            LocalCommandOutcome::Submit(prompt) => prompt,
            LocalCommandOutcome::Consumed | LocalCommandOutcome::PasteImage => {
                unreachable!("handled above")
            }
            LocalCommandOutcome::None => self.input.trim().to_string(),
        };
        if prompt.is_empty() && self.pending_attachments.is_empty() {
            self.clear_composer_input();
            self.reset_composer_scroll();
            return None;
        }
        let attachments = std::mem::take(&mut self.pending_attachments);
        self.clear_composer_input();
        self.reset_composer_scroll();
        match kind {
            SubmissionKind::Message => self.input_status = Some("message sent".to_string()),
            SubmissionKind::Steering => {
                self.input_status = Some("steer sent".to_string());
            }
        }
        Some(PromptInput {
            text: prompt,
            attachments,
            extra_text_parts: Vec::new(),
            guidance_text_parts: Vec::new(),
        })
    }

    pub(in crate::tui) fn clear_composer(&mut self) {
        self.clear_composer_input();
        self.reset_composer_scroll();
        self.pending_attachments.clear();
        self.input_status = None;
        self.history_index = None;
        self.footer_mode = FooterMode::Context;
    }

    pub(in crate::tui) fn backspace_composer(&mut self) {
        if self.remove_trailing_attachment_placeholder() {
            return;
        }
        let cursor = self.composer_cursor_byte();
        let Some(previous) = self.input[..cursor]
            .char_indices()
            .last()
            .map(|(index, _)| index)
        else {
            self.remove_last_pasted_image();
            return;
        };
        self.input.replace_range(previous..cursor, "");
        self.input_cursor = previous;
        self.input_cursor_input_len = self.input.len();
        self.reset_composer_scroll();
    }

    fn retain_visible_attachments(&mut self) {
        self.pending_attachments
            .retain(|attachment| self.input.contains(&attachment.placeholder));
    }

    fn remove_trailing_attachment_placeholder(&mut self) -> bool {
        let Some(attachment) = self.pending_attachments.last() else {
            return false;
        };
        let trimmed_input = self.input.trim_end_matches([' ', '\n']);
        let Some(prefix) = trimmed_input.strip_suffix(&attachment.placeholder) else {
            return false;
        };
        self.input.truncate(prefix.len());
        self.input_cursor = self.input.len();
        self.input_cursor_input_len = self.input.len();
        self.reset_composer_scroll();
        self.remove_last_pasted_image();
        true
    }

    fn remove_last_pasted_image(&mut self) {
        if self.pending_attachments.pop().is_some() {
            self.input_status = Some(if self.pending_attachments.is_empty() {
                "image detached".to_string()
            } else {
                format!("images attached: {}", self.pending_attachments.len())
            });
        }
    }

    pub(in crate::tui) const fn composer_is_empty(&self) -> bool {
        self.input.is_empty() && self.pending_attachments.is_empty()
    }

    pub(super) fn composer_has_draft(&self) -> bool {
        !self.input.trim().is_empty() || !self.pending_attachments.is_empty()
    }

    pub(in crate::tui) const fn composer_scroll_offset(&self) -> usize {
        self.input_scroll_offset
    }

    pub(in crate::tui::state) const fn reset_composer_scroll(&mut self) {
        self.input_scroll_offset = 0;
    }

    pub(in crate::tui) const fn scroll_composer_up(&mut self, amount: usize) {
        self.input_scroll_offset = self.input_scroll_offset.saturating_add(amount);
    }

    pub(in crate::tui) const fn scroll_composer_down(&mut self, amount: usize) {
        self.input_scroll_offset = self.input_scroll_offset.saturating_sub(amount);
    }

    pub(in crate::tui) fn clear_composer_input(&mut self) {
        self.input.clear();
        self.input_cursor = 0;
        self.input_cursor_input_len = 0;
    }

    pub(in crate::tui) fn composer_cursor_byte(&self) -> usize {
        if self.input_cursor_input_len != self.input.len() {
            return self.input.len();
        }
        previous_char_boundary(&self.input, self.input_cursor.min(self.input.len()))
    }

    pub(in crate::tui) fn move_composer_cursor_left(&mut self) {
        let cursor = self.composer_cursor_byte();
        if let Some(previous) = self.input[..cursor]
            .char_indices()
            .last()
            .map(|(index, _)| index)
        {
            self.input_cursor = previous;
            self.input_cursor_input_len = self.input.len();
            self.reset_composer_scroll();
        }
    }

    pub(in crate::tui) fn move_composer_cursor_right(&mut self) {
        let cursor = self.composer_cursor_byte();
        if cursor >= self.input.len() {
            self.move_composer_cursor_to_end();
            return;
        }
        let next = self.input[cursor..]
            .chars()
            .next()
            .map_or(self.input.len(), |ch| cursor + ch.len_utf8());
        self.input_cursor = next;
        self.input_cursor_input_len = self.input.len();
        self.reset_composer_scroll();
    }

    pub(in crate::tui) fn move_composer_cursor_word_left(&mut self) {
        let cursor = self.composer_cursor_byte();
        if cursor == 0 {
            return;
        }

        let mut target = cursor;
        let mut chars = self.input[..cursor].char_indices().rev().peekable();
        while let Some(&(index, ch)) = chars.peek() {
            if is_composer_word_char(ch) {
                break;
            }
            target = index;
            chars.next();
        }
        while let Some(&(index, ch)) = chars.peek() {
            if !is_composer_word_char(ch) {
                break;
            }
            target = index;
            chars.next();
        }

        self.input_cursor = target;
        self.input_cursor_input_len = self.input.len();
        self.reset_composer_scroll();
    }

    pub(in crate::tui) fn move_composer_cursor_word_right(&mut self) {
        let cursor = self.composer_cursor_byte();
        if cursor >= self.input.len() {
            self.move_composer_cursor_to_end();
            return;
        }

        let mut target = cursor;
        let mut chars = self.input[cursor..].char_indices().peekable();
        while let Some(&(offset, ch)) = chars.peek() {
            if is_composer_word_char(ch) {
                break;
            }
            target = cursor + offset + ch.len_utf8();
            chars.next();
        }
        while let Some(&(offset, ch)) = chars.peek() {
            if !is_composer_word_char(ch) {
                break;
            }
            target = cursor + offset + ch.len_utf8();
            chars.next();
        }

        self.input_cursor = target;
        self.input_cursor_input_len = self.input.len();
        self.reset_composer_scroll();
    }

    pub(in crate::tui) fn move_composer_cursor_to_line_start(&mut self) {
        let cursor = self.composer_cursor_byte();
        self.input_cursor = self.input[..cursor]
            .rfind('\n')
            .map_or(0, |index| index + 1);
        self.input_cursor_input_len = self.input.len();
        self.reset_composer_scroll();
    }

    pub(in crate::tui) fn move_composer_cursor_to_line_end(&mut self) {
        let cursor = self.composer_cursor_byte();
        self.input_cursor = self.input[cursor..]
            .find('\n')
            .map_or(self.input.len(), |offset| cursor + offset);
        self.input_cursor_input_len = self.input.len();
        self.reset_composer_scroll();
    }

    pub(in crate::tui::state) const fn move_composer_cursor_to_end(&mut self) {
        self.input_cursor = self.input.len();
        self.input_cursor_input_len = self.input.len();
    }

    pub(in crate::tui::state) fn insert_composer_str(&mut self, text: &str) {
        let cursor = self.composer_cursor_byte();
        self.input.insert_str(cursor, text);
        self.input_cursor = cursor + text.len();
        self.input_cursor_input_len = self.input.len();
        self.reset_composer_scroll();
        self.history_index = None;
    }

    pub(in crate::tui) fn push_composer_char(&mut self, ch: char) {
        let mut buffer = [0; 4];
        self.insert_composer_str(ch.encode_utf8(&mut buffer));
        self.input_status = None;
    }

    pub(in crate::tui) fn insert_composer_newline(&mut self) {
        self.insert_composer_str("\n");
    }

    pub(crate) const fn pasted_image_count(&self) -> usize {
        self.pending_attachments.len()
    }

    pub(in crate::tui) fn push_history(&mut self, prompt: String) {
        if self.history.last() != Some(&prompt) {
            self.history.push(prompt);
        }
        self.history_index = None;
        self.history_draft.clear();
    }

    pub(in crate::tui) fn previous_history(&mut self) {
        if self.history.is_empty() {
            return;
        }
        if self.history_index.is_none() {
            self.history_draft = self.input.clone();
            self.history_index = Some(self.history.len().saturating_sub(1));
        } else if let Some(index) = self.history_index.as_mut() {
            *index = index.saturating_sub(1);
        }
        if let Some(index) = self.history_index {
            self.input = self.history[index].clone();
            self.move_composer_cursor_to_end();
            self.reset_composer_scroll();
        }
    }

    pub(in crate::tui) fn next_history(&mut self) {
        let Some(index) = self.history_index else {
            return;
        };
        if index + 1 >= self.history.len() {
            self.history_index = None;
            self.input = self.history_draft.clone();
            self.history_draft.clear();
        } else {
            let next = index + 1;
            self.history_index = Some(next);
            self.input = self.history[next].clone();
        }
        self.move_composer_cursor_to_end();
        self.reset_composer_scroll();
    }
}