Codex Handbook
tui/src/bottom_pane/skill_popup.rs 390 lines
use crossterm::event::KeyCode;use ratatui::buffer::Buffer;use ratatui::layout::Constraint;use ratatui::layout::Layout;use ratatui::layout::Rect;use ratatui::text::Line;use ratatui::widgets::Widget;use ratatui::widgets::WidgetRef;use super::popup_consts::MAX_POPUP_ROWS;use super::scroll_state::ScrollState;use super::selection_popup_common::GenericDisplayRow;use super::selection_popup_common::render_rows_single_line;use crate::key_hint;use crate::render::Insets;use crate::render::RectExt;use crate::text_formatting::truncate_text;use codex_utils_fuzzy_match::fuzzy_match;#[derive(Clone, Debug)]pub(crate) struct MentionItem {    pub(crate) display_name: String,    pub(crate) description: Option<String>,    pub(crate) insert_text: String,    pub(crate) search_terms: Vec<String>,    pub(crate) path: Option<String>,    pub(crate) category_tag: Option<String>,    pub(crate) sort_rank: u8,}const MENTION_NAME_TRUNCATE_LEN: usize = 28;pub(crate) struct SkillPopup {    query: String,    mentions: Vec<MentionItem>,    state: ScrollState,}impl SkillPopup {    pub(crate) fn new(mentions: Vec<MentionItem>) -> Self {        Self {            query: String::new(),            mentions,            state: ScrollState::new(),        }    }    pub(crate) fn set_mentions(&mut self, mentions: Vec<MentionItem>) {        self.mentions = mentions;        self.clamp_selection();    }    pub(crate) fn set_query(&mut self, query: &str) {        self.query = query.to_string();        self.clamp_selection();    }    pub(crate) fn calculate_required_height(&self, _width: u16) -> u16 {        let rows = self.rows_from_matches(self.filtered());        let visible = rows.len().clamp(1, MAX_POPUP_ROWS);        (visible as u16).saturating_add(2)    }    pub(crate) fn move_up(&mut self) {        let len = self.filtered_items().len();        self.state.move_up_wrap(len);        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));    }    pub(crate) fn move_down(&mut self) {        let len = self.filtered_items().len();        self.state.move_down_wrap(len);        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));    }    pub(crate) fn selected_mention(&self) -> Option<&MentionItem> {        let matches = self.filtered_items();        let idx = self.state.selected_idx?;        let mention_idx = matches.get(idx)?;        self.mentions.get(*mention_idx)    }    fn clamp_selection(&mut self) {        let len = self.filtered_items().len();        self.state.clamp_selection(len);        self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));    }    fn filtered_items(&self) -> Vec<usize> {        self.filtered().into_iter().map(|(idx, _, _)| idx).collect()    }    fn rows_from_matches(        &self,        matches: Vec<(usize, Option<Vec<usize>>, i32)>,    ) -> Vec<GenericDisplayRow> {        matches            .into_iter()            .map(|(idx, indices, _score)| {                let mention = &self.mentions[idx];                let name = truncate_text(&mention.display_name, MENTION_NAME_TRUNCATE_LEN);                let description = match (                    mention.category_tag.as_deref(),                    mention.description.as_deref(),                ) {                    (Some(tag), Some(description)) if !description.is_empty() => {                        Some(format!("{tag} {description}"))                    }                    (Some(tag), _) => Some(tag.to_string()),                    (None, Some(description)) if !description.is_empty() => {                        Some(description.to_string())                    }                    _ => None,                };                GenericDisplayRow {                    name,                    name_prefix_spans: Vec::new(),                    match_indices: indices,                    display_shortcut: None,                    description,                    category_tag: None,                    is_disabled: false,                    disabled_reason: None,                    wrap_indent: None,                }            })            .collect()    }    fn filtered(&self) -> Vec<(usize, Option<Vec<usize>>, i32)> {        let filter = self.query.trim();        let mut out: Vec<(usize, Option<Vec<usize>>, i32)> = Vec::new();        for (idx, mention) in self.mentions.iter().enumerate() {            if filter.is_empty() {                out.push((idx, None, 0));                continue;            }            let best_match =                if let Some((indices, score)) = fuzzy_match(&mention.display_name, filter) {                    Some((Some(indices), score))                } else {                    mention                        .search_terms                        .iter()                        .filter(|term| *term != &mention.display_name)                        .filter_map(|term| fuzzy_match(term, filter).map(|(_indices, score)| score))                        .min()                        .map(|score| (None, score))                };            if let Some((indices, score)) = best_match {                out.push((idx, indices, score));            }        }        out.sort_by(|a, b| {            if filter.is_empty() {                self.mentions[a.0]                    .sort_rank                    .cmp(&self.mentions[b.0].sort_rank)            } else {                a.1.is_none()                    .cmp(&b.1.is_none())                    .then_with(|| a.2.cmp(&b.2))                    .then_with(|| {                        self.mentions[a.0]                            .sort_rank                            .cmp(&self.mentions[b.0].sort_rank)                    })            }            .then_with(|| {                let an = self.mentions[a.0].display_name.as_str();                let bn = self.mentions[b.0].display_name.as_str();                an.cmp(bn)            })        });        out    }}impl WidgetRef for SkillPopup {    fn render_ref(&self, area: Rect, buf: &mut Buffer) {        let (list_area, hint_area) = if area.height > 2 {            let [list_area, _spacer_area, hint_area] = Layout::vertical([                Constraint::Length(area.height - 2),                Constraint::Length(1),                Constraint::Length(1),            ])            .areas(area);            (list_area, Some(hint_area))        } else {            (area, None)        };        let rows = self.rows_from_matches(self.filtered());        render_rows_single_line(            list_area.inset(Insets::tlbr(                /*top*/ 0, /*left*/ 2, /*bottom*/ 0, /*right*/ 0,            )),            buf,            &rows,            &self.state,            MAX_POPUP_ROWS,            "no matches",        );        if let Some(hint_area) = hint_area {            let hint_area = Rect {                x: hint_area.x + 2,                y: hint_area.y,                width: hint_area.width.saturating_sub(2),                height: hint_area.height,            };            skill_popup_hint_line().render(hint_area, buf);        }    }}fn skill_popup_hint_line() -> Line<'static> {    Line::from(vec![        "Press ".into(),        key_hint::plain(KeyCode::Enter).into(),        " to insert or ".into(),        key_hint::plain(KeyCode::Esc).into(),        " to close".into(),    ])}#[cfg(test)]mod tests {    use super::*;    use pretty_assertions::assert_eq;    use ratatui::buffer::Buffer;    use ratatui::layout::Rect;    fn mention_item(index: usize) -> MentionItem {        MentionItem {            display_name: format!("Mention {index:02}"),            description: Some(format!("Description {index:02}")),            insert_text: format!("$mention-{index:02}"),            search_terms: vec![format!("mention-{index:02}")],            path: Some(format!("skill://mention-{index:02}")),            category_tag: Some("[Skill]".to_string()),            sort_rank: 1,        }    }    fn ranked_mention_item(        display_name: &str,        search_terms: &[&str],        category_tag: &str,        sort_rank: u8,    ) -> MentionItem {        MentionItem {            display_name: display_name.to_string(),            description: None,            insert_text: format!("${display_name}"),            search_terms: search_terms                .iter()                .map(|term| (*term).to_string())                .collect(),            path: None,            category_tag: Some(category_tag.to_string()),            sort_rank,        }    }    fn named_mention_item(display_name: &str, search_terms: &[&str]) -> MentionItem {        ranked_mention_item(display_name, search_terms, "[Skill]", /*sort_rank*/ 1)    }    fn plugin_mention_item(display_name: &str, search_terms: &[&str]) -> MentionItem {        ranked_mention_item(display_name, search_terms, "[Plugin]", /*sort_rank*/ 0)    }    #[test]    fn filtered_mentions_preserve_results_beyond_popup_height() {        let popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());        let filtered_names: Vec<String> = popup            .filtered_items()            .into_iter()            .map(|idx| popup.mentions[idx].display_name.clone())            .collect();        assert_eq!(            filtered_names,            (0..(MAX_POPUP_ROWS + 2))                .map(|idx| format!("Mention {idx:02}"))                .collect::<Vec<_>>()        );        assert_eq!(            popup.calculate_required_height(72),            (MAX_POPUP_ROWS as u16) + 2        );    }    fn render_popup(popup: &SkillPopup, width: u16) -> String {        let area = Rect::new(0, 0, width, popup.calculate_required_height(width));        let mut buf = Buffer::empty(area);        popup.render_ref(area, &mut buf);        format!("{buf:?}")    }    #[test]    fn scrolling_mentions_shifts_rendered_window_snapshot() {        let mut popup = SkillPopup::new((0..(MAX_POPUP_ROWS + 2)).map(mention_item).collect());        for _ in 0..=MAX_POPUP_ROWS {            popup.move_down();        }        insta::assert_snapshot!("skill_popup_scrolled", render_popup(&popup, /*width*/ 72));    }    #[test]    fn display_name_match_sorting_beats_worse_secondary_search_term_matches() {        let mut popup = SkillPopup::new(vec![            named_mention_item("pr-review-triage", &["pr-review-triage"]),            named_mention_item("prd", &["prd"]),            named_mention_item("PR Babysitter", &["babysit-pr", "PR Babysitter"]),            named_mention_item("Plugin Creator", &["plugin-creator", "Plugin Creator"]),            named_mention_item(                "Logging Best Practices",                &["logging-best-practices", "Logging Best Practices"],            ),        ]);        popup.set_query("pr");        let filtered_names: Vec<String> = popup            .filtered_items()            .into_iter()            .map(|idx| popup.mentions[idx].display_name.clone())            .collect();        assert_eq!(            filtered_names,            vec![                "PR Babysitter".to_string(),                "pr-review-triage".to_string(),                "prd".to_string(),                "Plugin Creator".to_string(),                "Logging Best Practices".to_string(),            ]        );    }    #[test]    fn query_match_score_sorts_before_plugin_rank_bias() {        let mut popup = SkillPopup::new(vec![            plugin_mention_item("GitHub", &["github", "pull requests", "pr"]),            named_mention_item("pr-review-triage", &["pr-review-triage"]),            named_mention_item("prd", &["prd"]),            named_mention_item("Plugin Creator", &["plugin-creator", "Plugin Creator"]),            named_mention_item(                "Logging Best Practices",                &["logging-best-practices", "Logging Best Practices"],            ),            named_mention_item("PR Babysitter", &["babysit-pr", "PR Babysitter"]),        ]);        popup.set_query("pr");        let filtered_items: Vec<(String, Option<String>)> = popup            .filtered_items()            .into_iter()            .map(|idx| {                (                    popup.mentions[idx].display_name.clone(),                    popup.mentions[idx].category_tag.clone(),                )            })            .collect();        assert_eq!(            filtered_items,            vec![                ("PR Babysitter".to_string(), Some("[Skill]".to_string())),                ("pr-review-triage".to_string(), Some("[Skill]".to_string())),                ("prd".to_string(), Some("[Skill]".to_string())),                ("Plugin Creator".to_string(), Some("[Skill]".to_string())),                (                    "Logging Best Practices".to_string(),                    Some("[Skill]".to_string())                ),                ("GitHub".to_string(), Some("[Plugin]".to_string())),            ]        );    }}