mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
2887f16cb9
commit
54801634e1
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user