rho-coding-agent 0.8.0

A lightweight agent harness inspired by Pi
use regex::RegexBuilder;

#[derive(Clone, Debug)]
pub(super) struct UiPicker {
    pub(super) title: String,
    pub(super) help: String,
    pub(super) items: Vec<PickerItem>,
    pub(super) selected: usize,
    pub(super) filter: String,
    pub(super) action: PickerAction,
}

#[derive(Clone, Debug)]
pub(super) struct PickerItem {
    pub(super) label: String,
    pub(super) detail: Option<String>,
    pub(super) preview: Option<String>,
    pub(super) badge: Option<PickerBadge>,
    pub(super) value: String,
}

#[derive(Clone, Debug)]
pub(super) struct PickerBadge {
    pub(super) text: String,
    pub(super) tone: PickerBadgeTone,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PickerBadgeTone {
    Selected,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum PickerAction {
    SelectModel,
    SelectTitleModel,
    LoginProvider,
    LogoutProvider,
    InsertSkillCommand,
    ResumeSession,
    Config,
}

impl UiPicker {
    pub(super) fn new(
        title: impl Into<String>,
        help: impl Into<String>,
        items: Vec<PickerItem>,
        action: PickerAction,
    ) -> Self {
        Self {
            title: title.into(),
            help: help.into(),
            items,
            selected: 0,
            filter: String::new(),
            action,
        }
    }

    pub(super) fn select_previous(&mut self) {
        let matches = self.matching_indices();
        if matches.is_empty() {
            return;
        }
        let position = matches
            .iter()
            .position(|index| *index == self.selected)
            .unwrap_or(0);
        self.selected = if position == 0 {
            *matches.last().unwrap()
        } else {
            matches[position - 1]
        };
    }

    pub(super) fn select_next(&mut self) {
        let matches = self.matching_indices();
        if matches.is_empty() {
            return;
        }
        let position = matches
            .iter()
            .position(|index| *index == self.selected)
            .unwrap_or(0);
        self.selected = matches[(position + 1) % matches.len()];
    }

    pub(super) fn push_filter_char(&mut self, ch: char) {
        self.filter.push(ch);
        self.select_first_match();
    }

    pub(super) fn pop_filter_char(&mut self) {
        self.filter.pop();
        self.select_first_match();
    }

    pub(super) fn complete_filter(&mut self) {
        if let Some(item) = self.selected_item() {
            self.filter = regex::escape(&item.value);
        }
    }

    pub(super) fn select_first_match(&mut self) {
        if let Some(index) = self.matching_indices().first().copied() {
            self.selected = index;
        }
    }

    pub(super) fn matching_indices(&self) -> Vec<usize> {
        picker_matching_indices(&self.items, &self.filter)
    }

    pub(super) fn selected_item(&self) -> Option<&PickerItem> {
        self.matching_indices()
            .contains(&self.selected)
            .then(|| self.items.get(self.selected))
            .flatten()
    }
}

pub(super) fn picker_matching_indices(items: &[PickerItem], filter: &str) -> Vec<usize> {
    let filter = filter.trim();
    if filter.is_empty() {
        return (0..items.len()).collect();
    }

    let Ok(regex) = RegexBuilder::new(filter).case_insensitive(true).build() else {
        return Vec::new();
    };

    items
        .iter()
        .enumerate()
        .filter_map(|(index, item)| {
            let detail = item.detail.as_deref().unwrap_or_default();
            let preview = item.preview.as_deref().unwrap_or_default();
            let badge = item
                .badge
                .as_ref()
                .map(|badge| badge.text.as_str())
                .unwrap_or_default();
            let haystack = format!(
                "{} {} {} {} {}",
                item.label, item.value, detail, preview, badge
            );
            regex.is_match(&haystack).then_some(index)
        })
        .collect()
}