diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 8116cf972..90106f31f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3584,6 +3584,13 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_display_names: HashSet = + 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::(); + 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| { diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index c1c529664..fe903a0b3 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -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::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 841ce23b8..061d3f3b8 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -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 = 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::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index 0e1f3e08d..06d8396f8 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -3599,6 +3599,13 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); + let plugin_display_names: HashSet = + 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::(); + 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| { diff --git a/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs index c1c529664..fe903a0b3 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/file_search_popup.rs @@ -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::>() + ); + assert_eq!(popup.calculate_required_height(), MAX_POPUP_ROWS as u16); + } +} diff --git a/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs index 841ce23b8..061d3f3b8 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/skill_popup.rs @@ -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 = 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::>() + ); + assert_eq!( + popup.calculate_required_height(72), + (MAX_POPUP_ROWS as u16) + 2 + ); + } +}