Label plugins as plugins, and hide skills/apps for given plugin (#15279)

- Duplicate app mentions are now suppressed when they’re plugin-backed
with the same display name.
- Remaining connector mentions now label category as [Plugin] when
plugin metadata is present, otherwise [App].
- Mention result lists are now capped to 8 rows after filtering.
- Updates both tui and tui_app_server with the same changes.
This commit is contained in:
canvrno-oai
2026-03-23 10:10:17 -07:00
committed by GitHub
Unverified
parent 2887f16cb9
commit 54801634e1
6 changed files with 358 additions and 4 deletions
+107 -1
View File
@@ -3584,6 +3584,13 @@ impl ChatComposer {
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
let plugin_display_names: HashSet<String> =
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
plugins
.iter()
.map(|plugin| plugin.display_name.to_ascii_lowercase())
.collect()
});
if let Some(skills) = self.skills.as_ref() {
for skill in skills {
@@ -3666,18 +3673,30 @@ impl ChatComposer {
if !connector.is_accessible || !connector.is_enabled {
continue;
}
let plugin_backed_connector = connector
.plugin_display_names
.iter()
.any(|name| plugin_display_names.contains(&name.to_ascii_lowercase()));
if plugin_backed_connector {
continue;
}
let display_name = connectors::connector_display_label(connector);
let description = Some(Self::connector_brief_description(connector));
let slug = codex_core::connectors::connector_mention_slug(connector);
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
let connector_id = connector.id.as_str();
let category_tag = if connector.plugin_display_names.is_empty() {
"[App]".to_string()
} else {
"[Plugin]".to_string()
};
mentions.push(MentionItem {
display_name: display_name.clone(),
description,
insert_text: format!("${slug}"),
search_terms,
path: Some(format!("app://{connector_id}")),
category_tag: Some("[App]".to_string()),
category_tag: Some(category_tag),
sort_rank: 1,
});
}
@@ -5383,6 +5402,93 @@ mod tests {
assert_eq!(mention.path, Some("plugin://sample@test".to_string()));
}
#[test]
fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_connectors_enabled(true);
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
composer.set_skill_mentions(Some(vec![SkillMetadata {
name: "google-calendar:availability".to_string(),
description: "Find availability and plan event changes".to_string(),
short_description: None,
interface: Some(codex_core::skills::model::SkillInterface {
display_name: Some("Google Calendar".to_string()),
short_description: None,
icon_small: None,
icon_large: None,
brand_color: None,
default_prompt: None,
}),
dependencies: None,
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"),
scope: codex_protocol::protocol::SkillScope::Repo,
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),
display_name: "Google Calendar".to_string(),
description: Some(
"Connect Google Calendar for scheduling, availability, and event management."
.to_string(),
),
has_skills: true,
mcp_server_names: vec!["google-calendar".to_string()],
app_connector_ids: vec![codex_core::plugins::AppConnectorId(
"google_calendar".to_string(),
)],
}]));
composer.set_connector_mentions(Some(ConnectorsSnapshot {
connectors: vec![AppInfo {
id: "google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Look up events and availability".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: vec!["Google Calendar".to_string()],
}],
}));
let mut mention_summaries: Vec<_> = composer
.mention_items()
.into_iter()
.map(|mention| (mention.display_name, mention.category_tag, mention.path))
.collect();
mention_summaries.sort();
assert_eq!(
mention_summaries,
vec![
(
"Google Calendar".to_string(),
Some("[Plugin]".to_string()),
Some("plugin://google-calendar@debug".to_string()),
),
(
"Google Calendar".to_string(),
Some("[Skill]".to_string()),
Some("/tmp/repo/google-calendar/SKILL.md".to_string()),
),
]
);
}
#[test]
fn plugin_mention_popup_snapshot() {
snapshot_composer_state("plugin_mention_popup", false, |composer| {
@@ -70,7 +70,7 @@ impl FileSearchPopup {
}
self.display_query = query.to_string();
self.matches = matches;
self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect();
self.waiting = false;
let len = self.matches.len();
self.state.clamp_selection(len);
@@ -152,3 +152,33 @@ impl WidgetRef for &FileSearchPopup {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_file_search::MatchType;
use pretty_assertions::assert_eq;
fn file_match(index: usize) -> FileMatch {
FileMatch {
score: index as u32,
path: PathBuf::from(format!("src/file_{index:02}.rs")),
match_type: MatchType::File,
root: PathBuf::from("/tmp/repo"),
indices: None,
}
}
#[test]
fn set_matches_keeps_only_the_first_page_of_results() {
let mut popup = FileSearchPopup::new();
popup.set_query("file");
popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect());
assert_eq!(
popup.matches,
(0..MAX_POPUP_ROWS).map(file_match).collect::<Vec<_>>()
);
assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16);
}
}
@@ -180,6 +180,7 @@ impl SkillPopup {
})
});
out.truncate(MAX_POPUP_ROWS);
out
}
}
@@ -229,3 +230,43 @@ fn skill_popup_hint_line() -> Line<'static> {
" to close".into(),
])
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
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,
}
}
#[test]
fn filtered_mentions_are_capped_to_max_popup_rows() {
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)
.map(|idx| format!("Mention {idx:02}"))
.collect::<Vec<_>>()
);
assert_eq!(
popup.calculate_required_height(72),
(MAX_POPUP_ROWS as u16) + 2
);
}
}
@@ -3599,6 +3599,13 @@ impl ChatComposer {
fn mention_items(&self) -> Vec<MentionItem> {
let mut mentions = Vec::new();
let plugin_display_names: HashSet<String> =
self.plugins.as_ref().map_or_else(HashSet::new, |plugins| {
plugins
.iter()
.map(|plugin| plugin.display_name.to_ascii_lowercase())
.collect()
});
if let Some(skills) = self.skills.as_ref() {
for skill in skills {
@@ -3681,18 +3688,30 @@ impl ChatComposer {
if !connector.is_accessible || !connector.is_enabled {
continue;
}
let plugin_backed_connector = connector
.plugin_display_names
.iter()
.any(|name| plugin_display_names.contains(&name.to_ascii_lowercase()));
if plugin_backed_connector {
continue;
}
let display_name = connectors::connector_display_label(connector);
let description = Some(Self::connector_brief_description(connector));
let slug = codex_core::connectors::connector_mention_slug(connector);
let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()];
let connector_id = connector.id.as_str();
let category_tag = if connector.plugin_display_names.is_empty() {
"[App]".to_string()
} else {
"[Plugin]".to_string()
};
mentions.push(MentionItem {
display_name: display_name.clone(),
description,
insert_text: format!("${slug}"),
search_terms,
path: Some(format!("app://{connector_id}")),
category_tag: Some("[App]".to_string()),
category_tag: Some(category_tag),
sort_rank: 1,
});
}
@@ -5398,6 +5417,93 @@ mod tests {
assert_eq!(mention.path, Some("plugin://sample@test".to_string()));
}
#[test]
fn mention_items_keep_plugin_owned_skills_but_hide_duplicate_apps() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_connectors_enabled(true);
composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new());
composer.set_skill_mentions(Some(vec![SkillMetadata {
name: "google-calendar:availability".to_string(),
description: "Find availability and plan event changes".to_string(),
short_description: None,
interface: Some(codex_core::skills::model::SkillInterface {
display_name: Some("Google Calendar".to_string()),
short_description: None,
icon_small: None,
icon_large: None,
brand_color: None,
default_prompt: None,
}),
dependencies: None,
policy: None,
permission_profile: None,
managed_network_override: None,
path_to_skills_md: PathBuf::from("/tmp/repo/google-calendar/SKILL.md"),
scope: codex_protocol::protocol::SkillScope::Repo,
}]));
composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary {
config_name: "google-calendar@debug".to_string(),
display_name: "Google Calendar".to_string(),
description: Some(
"Connect Google Calendar for scheduling, availability, and event management."
.to_string(),
),
has_skills: true,
mcp_server_names: vec!["google-calendar".to_string()],
app_connector_ids: vec![codex_core::plugins::AppConnectorId(
"google_calendar".to_string(),
)],
}]));
composer.set_connector_mentions(Some(ConnectorsSnapshot {
connectors: vec![AppInfo {
id: "google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Look up events and availability".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: true,
is_enabled: true,
plugin_display_names: vec!["Google Calendar".to_string()],
}],
}));
let mut mention_summaries: Vec<_> = composer
.mention_items()
.into_iter()
.map(|mention| (mention.display_name, mention.category_tag, mention.path))
.collect();
mention_summaries.sort();
assert_eq!(
mention_summaries,
vec![
(
"Google Calendar".to_string(),
Some("[Plugin]".to_string()),
Some("plugin://google-calendar@debug".to_string()),
),
(
"Google Calendar".to_string(),
Some("[Skill]".to_string()),
Some("/tmp/repo/google-calendar/SKILL.md".to_string()),
),
]
);
}
#[test]
fn plugin_mention_popup_snapshot() {
snapshot_composer_state("plugin_mention_popup", false, |composer| {
@@ -70,7 +70,7 @@ impl FileSearchPopup {
}
self.display_query = query.to_string();
self.matches = matches;
self.matches = matches.into_iter().take(MAX_POPUP_ROWS).collect();
self.waiting = false;
let len = self.matches.len();
self.state.clamp_selection(len);
@@ -152,3 +152,33 @@ impl WidgetRef for &FileSearchPopup {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_file_search::MatchType;
use pretty_assertions::assert_eq;
fn file_match(index: usize) -> FileMatch {
FileMatch {
score: index as u32,
path: PathBuf::from(format!("src/file_{index:02}.rs")),
match_type: MatchType::File,
root: PathBuf::from("/tmp/repo"),
indices: None,
}
}
#[test]
fn set_matches_keeps_only_the_first_page_of_results() {
let mut popup = FileSearchPopup::new();
popup.set_query("file");
popup.set_matches("file", (0..(MAX_POPUP_ROWS + 2)).map(file_match).collect());
assert_eq!(
popup.matches,
(0..MAX_POPUP_ROWS).map(file_match).collect::<Vec<_>>()
);
assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16);
}
}
@@ -180,6 +180,7 @@ impl SkillPopup {
})
});
out.truncate(MAX_POPUP_ROWS);
out
}
}
@@ -229,3 +230,43 @@ fn skill_popup_hint_line() -> Line<'static> {
" to close".into(),
])
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
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,
}
}
#[test]
fn filtered_mentions_are_capped_to_max_popup_rows() {
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)
.map(|idx| format!("Mention {idx:02}"))
.collect::<Vec<_>>()
);
assert_eq!(
popup.calculate_required_height(72),
(MAX_POPUP_ROWS as u16) + 2
);
}
}