mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
This updates TUI skill mentions to show a fallback label when a skill does not define a display name, so unnamed skills remain understandable in the picker without changing behavior for skills that already have one. <img width="1028" height="198" alt="Screenshot 2026-04-20 at 6 25 15 PM" src="https://github.com/user-attachments/assets/84077b85-99d0-4db9-b533-37e1887b4506" />
391 lines
13 KiB
Rust
391 lines
13 KiB
Rust
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())),
|
|
]
|
|
);
|
|
}
|
|
}
|