diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 6636278e1..e17940056 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -6281,7 +6281,7 @@ "type": "object" }, { - "description": "Explicit mention selected by the user (name + app://connector id).", + "description": "Explicit structured mention selected by the user.\n\n`path` identifies the exact mention target, for example `app://` or `plugin://@`.", "properties": { "name": { "type": "string" diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 6bdcc8258..49d216099 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -476,6 +476,26 @@ Invoke an app by including `$` in the text input and adding a `mention } } } ``` +### Example: Start a turn (invoke a plugin) + +Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/list`. + +```json +{ "method": "turn/start", "id": 35, "params": { + "threadId": "thr_123", + "input": [ + { "type": "text", "text": "@sample Summarize the latest updates." }, + { "type": "mention", "name": "Sample Plugin", "path": "plugin://sample@test" } + ] +} } +{ "id": 35, "result": { "turn": { + "id": "turn_459", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + ### Example: Interrupt an active turn You can cancel a running Turn with `turn/interrupt`. @@ -976,7 +996,7 @@ The server also emits `app/list/updated` notifications whenever either source (a } ``` -Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. +Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/list`. Example: diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ca351e883..41cc7771b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5129,7 +5129,7 @@ pub(crate) async fn run_turn( .services .plugins_manager .plugins_for_config(&turn_context.config); - // Plain-text @plugin mentions are resolved from the current session's + // Structured plugin:// mentions are resolved from the current session's // enabled plugins, then converted into turn-scoped guidance below. let mentioned_plugins = collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries()); @@ -5226,7 +5226,7 @@ pub(crate) async fn run_turn( &available_connectors, &skill_name_counts_lower, )); - // Explicit @plugin mentions can make a plugin's enabled apps callable for + // Explicit plugin mentions can make a plugin's enabled apps callable for // this turn without persisting those connectors as sticky user selections. let mut turn_enabled_connectors = explicitly_enabled_connectors.clone(); turn_enabled_connectors.extend( diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/mentions.rs index 79efa3ec7..0c7a4fff0 100644 --- a/codex-rs/core/src/mentions.rs +++ b/codex-rs/core/src/mentions.rs @@ -10,6 +10,7 @@ use crate::skills::SkillMetadata; use crate::skills::injection::ToolMentionKind; use crate::skills::injection::app_id_from_path; use crate::skills::injection::extract_tool_mentions; +use crate::skills::injection::plugin_config_name_from_path; use crate::skills::injection::tool_kind_for_path; pub(crate) struct CollectedToolMentions { @@ -49,20 +50,7 @@ pub(crate) fn collect_explicit_app_ids(input: &[UserInput]) -> HashSet { .collect() } -/// Collect explicit plain-text `@plugin` mentions from user text. -/// -/// This is currently the core-side fallback path for plugin mentions. It -/// matches unambiguous plugin `display_name`s from the filtered capability -/// index, case-insensitively, by scanning for exact `@display name` matches. -/// -/// It is hand-rolled because core only has a `$...` / `[$...](...)` mention -/// parser today, and the existing TUI `@...` logic is file-autocomplete, not -/// turn-time parsing. -/// -/// Long term, explicit plugin picks should come through structured -/// `plugin://...` mentions, likely via `UserInput::Mention`, once clients can list -/// plugins and the UI has plugin-mention support (likely a plugins/list app-server -/// endpoint). Even then, this may stay as a text fallback, similar to skills/apps. +/// Collect explicit structured `plugin://...` mentions. pub(crate) fn collect_explicit_plugin_mentions( input: &[UserInput], plugins: &[PluginCapabilitySummary], @@ -71,79 +59,34 @@ pub(crate) fn collect_explicit_plugin_mentions( return Vec::new(); } - let mut display_name_counts = HashMap::new(); - for plugin in plugins { - *display_name_counts - .entry(plugin.display_name.to_lowercase()) - .or_insert(0) += 1; - } + let messages = input + .iter() + .filter_map(|item| match item { + UserInput::Text { text, .. } => Some(text.clone()), + _ => None, + }) + .collect::>(); - let mut display_names = display_name_counts.keys().cloned().collect::>(); - display_names.sort_by_key(|display_name| std::cmp::Reverse(display_name.len())); + let mentioned_config_names: HashSet = input + .iter() + .filter_map(|item| match item { + UserInput::Mention { path, .. } => Some(path.clone()), + _ => None, + }) + .chain(collect_tool_mentions_from_messages(&messages).paths) + .filter(|path| tool_kind_for_path(path.as_str()) == ToolMentionKind::Plugin) + .filter_map(|path| plugin_config_name_from_path(path.as_str()).map(str::to_string)) + .collect(); - let mut mentioned_display_names = HashSet::new(); - for text in input.iter().filter_map(|item| match item { - UserInput::Text { text, .. } => Some(text.as_str()), - _ => None, - }) { - let text = text.to_lowercase(); - let mut index = 0; - while let Some(relative_at_sign) = text[index..].find('@') { - let at_sign = index + relative_at_sign; - if text[..at_sign] - .chars() - .next_back() - .is_some_and(is_plugin_mention_body_char) - { - index = at_sign + 1; - continue; - } - - let Some((matched_display_name, matched_len)) = - display_names.iter().find_map(|display_name| { - text[at_sign + 1..].starts_with(display_name).then(|| { - let end = at_sign + 1 + display_name.len(); - text[end..] - .chars() - .next() - .is_none_or(|ch| !is_plugin_mention_body_char(ch)) - .then_some((display_name, display_name.len())) - })? - }) - else { - index = at_sign + 1; - continue; - }; - - if display_name_counts - .get(matched_display_name) - .copied() - .unwrap_or(0) - == 1 - { - mentioned_display_names.insert(matched_display_name.clone()); - } - index = at_sign + 1 + matched_len; - } - } - - if mentioned_display_names.is_empty() { + if mentioned_config_names.is_empty() { return Vec::new(); } - let mut selected = Vec::new(); - let mut seen_display_names = HashSet::new(); - for plugin in plugins { - let display_name = plugin.display_name.to_lowercase(); - if !mentioned_display_names.contains(&display_name) { - continue; - } - if seen_display_names.insert(display_name) { - selected.push(plugin.clone()); - } - } - - selected + plugins + .iter() + .filter(|plugin| mentioned_config_names.contains(plugin.config_name.as_str())) + .cloned() + .collect() } pub(crate) fn build_skill_name_counts( @@ -175,10 +118,6 @@ pub(crate) fn build_connector_slug_counts( counts } -fn is_plugin_mention_body_char(ch: char) -> bool { - ch.is_alphanumeric() || matches!(ch, '_' | '-' | ':') -} - #[cfg(test)] mod tests { use std::collections::HashSet; @@ -197,10 +136,11 @@ mod tests { } } - fn plugin(display_name: &str) -> PluginCapabilitySummary { + fn plugin(config_name: &str, display_name: &str) -> PluginCapabilitySummary { PluginCapabilitySummary { - config_name: format!("{display_name}@test"), + config_name: config_name.to_string(), display_name: display_name.to_string(), + description: None, has_skills: true, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), @@ -257,65 +197,67 @@ mod tests { } #[test] - fn collect_explicit_plugin_mentions_resolves_unique_display_names() { - let plugins = vec![plugin("sample"), plugin("other")]; - - let mentioned = collect_explicit_plugin_mentions(&[text_input("use @sample")], &plugins); - - assert_eq!(mentioned, vec![plugin("sample")]); - } - - #[test] - fn collect_explicit_plugin_mentions_resolves_non_slug_display_names() { - let spaced_plugins = vec![plugin("Google Calendar")]; - let spaced_mentioned = collect_explicit_plugin_mentions( - &[text_input("use @Google Calendar")], - &spaced_plugins, - ); - assert_eq!(spaced_mentioned, vec![plugin("Google Calendar")]); - - let unicode_plugins = vec![plugin("Café")]; - let unicode_mentioned = - collect_explicit_plugin_mentions(&[text_input("use @Café")], &unicode_plugins); - assert_eq!(unicode_mentioned, vec![plugin("Café")]); - } - - #[test] - fn collect_explicit_plugin_mentions_prefers_longer_display_names() { - let plugins = vec![plugin("Google"), plugin("Google Calendar")]; - - let mentioned = - collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins); - - assert_eq!(mentioned, vec![plugin("Google Calendar")]); - } - - #[test] - fn collect_explicit_plugin_mentions_does_not_fall_back_from_ambiguous_longer_name() { + fn collect_explicit_plugin_mentions_from_structured_paths() { let plugins = vec![ - plugin("Google"), - PluginCapabilitySummary { - config_name: "calendar-1@test".to_string(), - ..plugin("Google Calendar") - }, - PluginCapabilitySummary { - config_name: "calendar-2@test".to_string(), - ..plugin("Google Calendar") - }, + plugin("sample@test", "sample"), + plugin("other@test", "other"), ]; - let mentioned = - collect_explicit_plugin_mentions(&[text_input("use @Google Calendar")], &plugins); + let mentioned = collect_explicit_plugin_mentions( + &[UserInput::Mention { + name: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + &plugins, + ); - assert_eq!(mentioned, Vec::::new()); + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); } #[test] - fn collect_explicit_plugin_mentions_ignores_embedded_at_signs() { - let plugins = vec![plugin("sample")]; + fn collect_explicit_plugin_mentions_from_linked_text_mentions() { + let plugins = vec![ + plugin("sample@test", "sample"), + plugin("other@test", "other"), + ]; let mentioned = collect_explicit_plugin_mentions( - &[text_input("contact sample@openai.com, do not use plugins")], + &[text_input("use [$sample](plugin://sample@test)")], + &plugins, + ); + + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); + } + + #[test] + fn collect_explicit_plugin_mentions_dedupes_structured_and_linked_mentions() { + let plugins = vec![ + plugin("sample@test", "sample"), + plugin("other@test", "other"), + ]; + + let mentioned = collect_explicit_plugin_mentions( + &[ + text_input("use [$sample](plugin://sample@test)"), + UserInput::Mention { + name: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, + ], + &plugins, + ); + + assert_eq!(mentioned, vec![plugin("sample@test", "sample")]); + } + + #[test] + fn collect_explicit_plugin_mentions_ignores_non_plugin_paths() { + let plugins = vec![plugin("sample@test", "sample")]; + + let mentioned = collect_explicit_plugin_mentions( + &[text_input( + "use [$app](app://calendar) and [$skill](skill://team/skill) and [$file](/tmp/file.txt)", + )], &plugins, ); diff --git a/codex-rs/core/src/plugins/injection.rs b/codex-rs/core/src/plugins/injection.rs index d8adfc92c..00b15426f 100644 --- a/codex-rs/core/src/plugins/injection.rs +++ b/codex-rs/core/src/plugins/injection.rs @@ -19,7 +19,7 @@ pub(crate) fn build_plugin_injections( return Vec::new(); } - // Turn each explicit @plugin mention into a developer hint that points the + // Turn each explicit plugin mention into a developer hint that points the // model at the plugin's visible MCP servers, enabled apps, and skill prefix. mentioned_plugins .iter() diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 2e45a2b3e..0938b39cc 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -66,6 +66,7 @@ pub struct ConfiguredMarketplacePluginSummary { pub struct LoadedPlugin { pub config_name: String, pub manifest_name: Option, + pub manifest_description: Option, pub root: AbsolutePathBuf, pub enabled: bool, pub skill_roots: Vec, @@ -84,6 +85,7 @@ impl LoadedPlugin { pub struct PluginCapabilitySummary { pub config_name: String, pub display_name: String, + pub description: Option, pub has_skills: bool, pub mcp_server_names: Vec, pub app_connector_ids: Vec, @@ -104,6 +106,7 @@ impl PluginCapabilitySummary { .manifest_name .clone() .unwrap_or_else(|| plugin.config_name.clone()), + description: plugin.manifest_description.clone(), has_skills: !plugin.skill_roots.is_empty(), mcp_server_names, app_connector_ids: plugin.apps.clone(), @@ -476,6 +479,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) let mut loaded_plugin = LoadedPlugin { config_name, manifest_name: None, + manifest_description: None, root, enabled: plugin.enabled, skill_roots: Vec::new(), @@ -507,6 +511,7 @@ fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) }; loaded_plugin.manifest_name = Some(plugin_manifest_name(&manifest, plugin_root.as_path())); + loaded_plugin.manifest_description = manifest.description; loaded_plugin.skill_roots = default_skill_roots(plugin_root.as_path()); let mut mcp_servers = HashMap::new(); for mcp_config_path in default_mcp_config_paths(plugin_root.as_path()) { @@ -752,7 +757,10 @@ mod tests { write_file( &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, + r#"{ + "name": "sample", + "description": "Plugin that includes the sample MCP server and Skills" +}"#, ); write_file( &plugin_root.join("skills/sample-search/SKILL.md"), @@ -792,6 +800,9 @@ mod tests { vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: Some("sample".to_string()), + manifest_description: Some( + "Plugin that includes the sample MCP server and Skills".to_string(), + ), root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, skill_roots: vec![plugin_root.join("skills")], @@ -819,6 +830,19 @@ mod tests { error: None, }] ); + assert_eq!( + outcome.capability_summaries(), + &[PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: Some( + "Plugin that includes the sample MCP server and Skills".to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: vec![AppConnectorId("connector_example".to_string())], + }] + ); assert_eq!( outcome.effective_skill_roots(), vec![plugin_root.join("skills")] @@ -862,6 +886,7 @@ mod tests { vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: None, + manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, skill_roots: Vec::new(), @@ -972,6 +997,7 @@ mod tests { let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin { config_name: config_name.to_string(), manifest_name: Some(manifest_name.to_string()), + manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), @@ -982,6 +1008,7 @@ mod tests { let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary { config_name: config_name.to_string(), display_name: display_name.to_string(), + description: None, ..PluginCapabilitySummary::default() }; let outcome = PluginLoadOutcome::from_plugins(vec![ diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index ad677de81..755db5d50 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -6,7 +6,8 @@ pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; #[derive(Debug, Default, Deserialize)] pub(crate) struct PluginManifest { - name: String, + pub(crate) name: String, + pub(crate) description: Option, } pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option { diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index e293aac59..de50d6cdd 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -178,12 +178,14 @@ impl<'a> ToolMentions<'a> { pub(crate) enum ToolMentionKind { App, Mcp, + Plugin, Skill, Other, } const APP_PATH_PREFIX: &str = "app://"; const MCP_PATH_PREFIX: &str = "mcp://"; +const PLUGIN_PATH_PREFIX: &str = "plugin://"; const SKILL_PATH_PREFIX: &str = "skill://"; const SKILL_FILENAME: &str = "SKILL.md"; @@ -192,6 +194,8 @@ pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind { ToolMentionKind::App } else if path.starts_with(MCP_PATH_PREFIX) { ToolMentionKind::Mcp + } else if path.starts_with(PLUGIN_PATH_PREFIX) { + ToolMentionKind::Plugin } else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) { ToolMentionKind::Skill } else { @@ -209,6 +213,11 @@ pub(crate) fn app_id_from_path(path: &str) -> Option<&str> { .filter(|value| !value.is_empty()) } +pub(crate) fn plugin_config_name_from_path(path: &str) -> Option<&str> { + path.strip_prefix(PLUGIN_PATH_PREFIX) + .filter(|value| !value.is_empty()) +} + pub(crate) fn normalize_skill_path(path: &str) -> &str { path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path) } @@ -219,6 +228,10 @@ pub(crate) fn normalize_skill_path(path: &str) -> &str { /// resource path is present, it is captured for exact path matching while also tracking /// the name for fallback matching. pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { + extract_tool_mentions_with_sigil(text, '$') +} + +fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> { let text_bytes = text.as_bytes(); let mut mentioned_names: HashSet<&str> = HashSet::new(); let mut mentioned_paths: HashSet<&str> = HashSet::new(); @@ -229,11 +242,13 @@ pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { let byte = text_bytes[index]; if byte == b'[' && let Some((name, path, end_index)) = - parse_linked_tool_mention(text, text_bytes, index) + parse_linked_tool_mention(text, text_bytes, index, sigil) { if !is_common_env_var(name) { - let kind = tool_kind_for_path(path); - if !matches!(kind, ToolMentionKind::App | ToolMentionKind::Mcp) { + if !matches!( + tool_kind_for_path(path), + ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin + ) { mentioned_names.insert(name); } mentioned_paths.insert(path); @@ -242,7 +257,7 @@ pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { continue; } - if byte != b'$' { + if byte != sigil as u8 { index += 1; continue; } @@ -297,7 +312,7 @@ fn select_skills_from_mentions( .filter(|path| { !matches!( tool_kind_for_path(path), - ToolMentionKind::App | ToolMentionKind::Mcp + ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin ) }) .map(normalize_skill_path) @@ -361,13 +376,14 @@ fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, + sigil: char, ) -> Option<(&'a str, &'a str, usize)> { - let dollar_index = start + 1; - if text_bytes.get(dollar_index) != Some(&b'$') { + let sigil_index = start + 1; + if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } - let name_start = dollar_index + 1; + let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 4e4510dde..f0c952e07 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -684,7 +684,7 @@ fn create_collab_input_items_schema() -> JsonSchema { "path".to_string(), JsonSchema::String { description: Some( - "Path when type is local_image/skill, or mention target such as app:// when type is mention." + "Path when type is local_image/skill, or structured mention target such as app:// or plugin://@ when type is mention." .to_string(), ), }, diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index d64702bc9..441921cb2 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -243,9 +243,9 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { codex .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Text { - text: "Use @sample for this task.".into(), - text_elements: Vec::new(), + items: vec![codex_protocol::user_input::UserInput::Mention { + name: "sample".into(), + path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], final_output_json_schema: None, }) diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index c1c22dcaf..4ed112df8 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -32,7 +32,10 @@ pub enum UserInput { name: String, path: std::path::PathBuf, }, - /// Explicit mention selected by the user (name + app://connector id). + /// Explicit structured mention selected by the user. + /// + /// `path` identifies the exact mention target, for example + /// `app://` or `plugin://@`. Mention { name: String, path: String }, } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 35fd5229b..daaab43ac 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -213,6 +213,7 @@ use crate::tui::FrameRequester; use crate::ui_consts::LIVE_PREFIX_COLS; use codex_chatgpt::connectors; use codex_chatgpt::connectors::AppInfo; +use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -391,6 +392,7 @@ pub(crate) struct ChatComposer { next_element_id: u64, context_window_used_tokens: Option, skills: Option>, + plugins: Option>, connectors_snapshot: Option, dismissed_mention_popup_token: Option, mention_bindings: HashMap, @@ -510,6 +512,7 @@ impl ChatComposer { next_element_id: 0, context_window_used_tokens: None, skills: None, + plugins: None, connectors_snapshot: None, dismissed_mention_popup_token: None, mention_bindings: HashMap::new(), @@ -546,6 +549,11 @@ impl ChatComposer { self.skills = skills; } + pub fn set_plugin_mentions(&mut self, plugins: Option>) { + self.plugins = plugins; + self.sync_popups(); + } + /// Toggle composer-side image paste handling. /// /// This only affects whether image-like paste content is converted into attachments; the @@ -1926,17 +1934,25 @@ impl ChatComposer { self.skills.as_ref() } + pub fn plugins(&self) -> Option<&Vec> { + self.plugins.as_ref() + } + fn mentions_enabled(&self) -> bool { let skills_ready = self .skills .as_ref() .is_some_and(|skills| !skills.is_empty()); + let plugins_ready = self + .plugins + .as_ref() + .is_some_and(|plugins| !plugins.is_empty()); let connectors_ready = self.connectors_enabled && self .connectors_snapshot .as_ref() .is_some_and(|snapshot| !snapshot.connectors.is_empty()); - skills_ready || connectors_ready + skills_ready || plugins_ready || connectors_ready } /// Extract a token prefixed with `prefix` under the cursor, if any. @@ -3559,6 +3575,58 @@ impl ChatComposer { } } + if let Some(plugins) = self.plugins.as_ref() { + for plugin in plugins { + let (plugin_name, marketplace_name) = plugin + .config_name + .split_once('@') + .unwrap_or((plugin.config_name.as_str(), "")); + let mut capability_labels = Vec::new(); + if plugin.has_skills { + capability_labels.push("skills".to_string()); + } + if !plugin.mcp_server_names.is_empty() { + let mcp_server_count = plugin.mcp_server_names.len(); + capability_labels.push(if mcp_server_count == 1 { + "1 MCP server".to_string() + } else { + format!("{mcp_server_count} MCP servers") + }); + } + if !plugin.app_connector_ids.is_empty() { + let app_count = plugin.app_connector_ids.len(); + capability_labels.push(if app_count == 1 { + "1 app".to_string() + } else { + format!("{app_count} apps") + }); + } + let description = plugin.description.clone().or_else(|| { + Some(if capability_labels.is_empty() { + "Plugin".to_string() + } else { + format!("Plugin · {}", capability_labels.join(" · ")) + }) + }); + let mut search_terms = vec![plugin_name.to_string(), plugin.config_name.clone()]; + if plugin.display_name != plugin_name { + search_terms.push(plugin.display_name.clone()); + } + if !marketplace_name.is_empty() { + search_terms.push(marketplace_name.to_string()); + } + mentions.push(MentionItem { + display_name: plugin.display_name.clone(), + description, + insert_text: format!("${plugin_name}"), + search_terms, + path: Some(format!("plugin://{}", plugin.config_name)), + category_tag: (!marketplace_name.is_empty()) + .then(|| format!("[{marketplace_name}]")), + }); + } + } + if self.connectors_enabled && let Some(snapshot) = self.connectors_snapshot.as_ref() { @@ -5212,6 +5280,59 @@ mod tests { assert_eq!(mention.path, Some("app://connector_1".to_string())); } + #[test] + fn set_plugin_mentions_refreshes_open_mention_popup() { + 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_text_content("$".to_string(), Vec::new(), Vec::new()); + assert!(matches!(composer.active_popup, ActivePopup::None)); + + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + description: None, + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: Vec::new(), + }])); + + let ActivePopup::Skill(popup) = &composer.active_popup else { + panic!("expected mention popup to open after plugin update"); + }; + let mention = popup + .selected_mention() + .expect("expected plugin mention to be selected"); + assert_eq!(mention.insert_text, "$sample".to_string()); + assert_eq!(mention.path, Some("plugin://sample@test".to_string())); + } + + #[test] + fn plugin_mention_popup_snapshot() { + snapshot_composer_state("plugin_mention_popup", false, |composer| { + composer.set_text_content("$sa".to_string(), Vec::new(), Vec::new()); + composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + description: Some( + "Plugin that includes the Figma MCP server and Skills for common workflows" + .to_string(), + ), + has_skills: true, + mcp_server_names: vec!["sample".to_string()], + app_connector_ids: vec![codex_core::plugins::AppConnectorId( + "calendar".to_string(), + )], + }])); + }); + } + #[test] fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 332bb150f..24a4c959e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -28,6 +28,7 @@ use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; use codex_core::features::Features; +use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use codex_protocol::request_user_input::RequestUserInputEvent; @@ -254,6 +255,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_plugin_mentions(&mut self, plugins: Option>) { + self.composer.set_plugin_mentions(plugins); + self.request_redraw(); + } + pub fn take_mention_bindings(&mut self) -> Vec { self.composer.take_mention_bindings() } @@ -333,6 +339,10 @@ impl BottomPane { self.composer.skills() } + pub fn plugins(&self) -> Option<&Vec> { + self.composer.plugins() + } + #[cfg(test)] pub(crate) fn context_window_percent(&self) -> Option { self.context_window_percent diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap new file mode 100644 index 000000000..636cf8881 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__plugin_mention_popup.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› $sa " +" " +" " +" " +" " +" Sample Plugin Plugin that includes the Figma MCP server and Skills for common workflows " +" " +" Press enter to insert or esc to close " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 90b1cab01..9f2295e65 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1225,6 +1225,7 @@ impl ChatWidget { self.refresh_model_display(); self.sync_fast_command_enabled(); self.sync_personality_command_enabled(); + self.refresh_plugin_mentions(); let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self.should_show_fast_status(event.service_tier); let session_info_cell = history_cell::new_session_info( @@ -4411,6 +4412,7 @@ impl ChatWidget { .collect(); let mut skill_names_lower: HashSet = HashSet::new(); let mut selected_skill_paths: HashSet = HashSet::new(); + let mut selected_plugin_ids: HashSet = HashSet::new(); if let Some(skills) = self.bottom_pane.skills() { skill_names_lower = skills @@ -4450,6 +4452,30 @@ impl ChatWidget { } } + if let Some(plugins) = self.plugins_for_mentions() { + for binding in &mention_bindings { + let Some(plugin_config_name) = binding + .path + .strip_prefix("plugin://") + .filter(|id| !id.is_empty()) + else { + continue; + }; + if !selected_plugin_ids.insert(plugin_config_name.to_string()) { + continue; + } + if let Some(plugin) = plugins + .iter() + .find(|plugin| plugin.config_name == plugin_config_name) + { + items.push(UserInput::Mention { + name: plugin.display_name.clone(), + path: binding.path.clone(), + }); + } + } + } + let mut selected_app_ids: HashSet = HashSet::new(); if let Some(apps) = self.connectors_for_mentions() { for binding in &mention_bindings { @@ -7153,6 +7179,9 @@ impl ChatWidget { if feature == Feature::Personality { self.sync_personality_command_enabled(); } + if feature == Feature::Plugins { + self.refresh_plugin_mentions(); + } if feature == Feature::PreventIdleSleep { self.turn_sleep_inhibitor = SleepInhibitor::new(enabled); self.turn_sleep_inhibitor @@ -7578,6 +7607,14 @@ impl ChatWidget { } } + fn plugins_for_mentions(&self) -> Option<&[codex_core::plugins::PluginCapabilitySummary]> { + if !self.config.features.enabled(Feature::Plugins) { + return None; + } + + self.bottom_pane.plugins().map(Vec::as_slice) + } + /// Build a placeholder header cell while the session is configuring. fn placeholder_session_header_cell(config: &Config) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); @@ -8077,6 +8114,7 @@ impl ChatWidget { fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { self.set_skills_from_response(&ev); + self.refresh_plugin_mentions(); } pub(crate) fn on_connectors_loaded( @@ -8160,6 +8198,19 @@ impl ChatWidget { self.bottom_pane.set_connectors_snapshot(Some(snapshot)); } + fn refresh_plugin_mentions(&mut self) { + if !self.config.features.enabled(Feature::Plugins) { + self.bottom_pane.set_plugin_mentions(None); + return; + } + + let plugins = PluginsManager::new(self.config.codex_home.clone()) + .plugins_for_config(&self.config) + .capability_summaries() + .to_vec(); + self.bottom_pane.set_plugin_mentions(Some(plugins)); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index efca42872..32dcb1ae8 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -296,7 +296,13 @@ pub(crate) struct ToolMentions { linked_paths: HashMap, } +const TOOL_MENTION_SIGIL: char = '$'; + fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { + extract_tool_mentions_from_text_with_sigil(text, TOOL_MENTION_SIGIL) +} + +fn extract_tool_mentions_from_text_with_sigil(text: &str, sigil: char) -> ToolMentions { let text_bytes = text.as_bytes(); let mut names: HashSet = HashSet::new(); let mut linked_paths: HashMap = HashMap::new(); @@ -306,10 +312,10 @@ fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { let byte = text_bytes[index]; if byte == b'[' && let Some((name, path, end_index)) = - parse_linked_tool_mention(text, text_bytes, index) + parse_linked_tool_mention(text, text_bytes, index, sigil) { if !is_common_env_var(name) { - if !is_app_or_mcp_path(path) { + if is_skill_path(path) { names.insert(name.to_string()); } linked_paths @@ -320,7 +326,7 @@ fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { continue; } - if byte != b'$' { + if byte != sigil as u8 { index += 1; continue; } @@ -359,13 +365,14 @@ fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, + sigil: char, ) -> Option<(&'a str, &'a str, usize)> { - let dollar_index = start + 1; - if text_bytes.get(dollar_index) != Some(&b'$') { + let sigil_index = start + 1; + if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } - let name_start = dollar_index + 1; + let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; @@ -434,7 +441,7 @@ fn is_mention_name_char(byte: u8) -> bool { } fn is_skill_path(path: &str) -> bool { - !is_app_or_mcp_path(path) + !path.starts_with("app://") && !path.starts_with("mcp://") && !path.starts_with("plugin://") } fn normalize_skill_path(path: &str) -> &str { @@ -445,7 +452,3 @@ fn app_id_from_path(path: &str) -> Option<&str> { path.strip_prefix("app://") .filter(|value| !value.is_empty()) } - -fn is_app_or_mcp_path(path: &str) -> bool { - path.starts_with("app://") || path.starts_with("mcp://") -} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ce34b598b..ddcd015c6 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4141,6 +4141,73 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements() assert!(stored_remote_image_urls.is_empty()); } +#[tokio::test(flavor = "multi_thread")] +async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_protocol::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + chat.set_feature_enabled(Feature::Plugins, true); + chat.bottom_pane.set_plugin_mentions(Some(vec![ + codex_core::plugins::PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "Sample Plugin".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + ])); + + chat.submit_user_message(UserMessage { + text: "$sample".to_string(), + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: vec![MentionBinding { + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }], + }); + + let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else { + panic!("expected Op::UserTurn"); + }; + assert_eq!( + items, + vec![ + UserInput::Text { + text: "$sample".to_string(), + text_elements: Vec::new(), + }, + UserInput::Mention { + name: "Sample Plugin".to_string(), + path: "plugin://sample@test".to_string(), + }, + ] + ); +} + #[tokio::test] async fn steer_enter_during_final_stream_preserves_follow_up_prompts_in_order() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/mention_codec.rs b/codex-rs/tui/src/mention_codec.rs index 5a7670e14..a517896a9 100644 --- a/codex-rs/tui/src/mention_codec.rs +++ b/codex-rs/tui/src/mention_codec.rs @@ -7,6 +7,8 @@ pub(crate) struct LinkedMention { pub(crate) path: String, } +const TOOL_MENTION_SIGIL: char = '$'; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct DecodedHistoryText { pub(crate) text: String, @@ -31,7 +33,7 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) -> let mut index = 0usize; while index < bytes.len() { - if bytes[index] == b'$' { + if bytes[index] == TOOL_MENTION_SIGIL as u8 { let name_start = index + 1; if let Some(first) = bytes.get(name_start) && is_mention_name_char(*first) @@ -46,7 +48,7 @@ pub(crate) fn encode_history_mentions(text: &str, mentions: &[LinkedMention]) -> let name = &text[name_start..name_end]; if let Some(path) = mentions_by_name.get_mut(name).and_then(VecDeque::pop_front) { out.push('['); - out.push('$'); + out.push(TOOL_MENTION_SIGIL); out.push_str(name); out.push_str("]("); out.push_str(path); @@ -75,11 +77,12 @@ pub(crate) fn decode_history_mentions(text: &str) -> DecodedHistoryText { while index < bytes.len() { if bytes[index] == b'[' - && let Some((name, path, end_index)) = parse_linked_tool_mention(text, bytes, index) + && let Some((name, path, end_index)) = + parse_linked_tool_mention(text, bytes, index, TOOL_MENTION_SIGIL) && !is_common_env_var(name) && is_tool_path(path) { - out.push('$'); + out.push(TOOL_MENTION_SIGIL); out.push_str(name); mentions.push(LinkedMention { mention: name.to_string(), @@ -106,13 +109,14 @@ fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, + sigil: char, ) -> Option<(&'a str, &'a str, usize)> { - let dollar_index = start + 1; - if text_bytes.get(dollar_index) != Some(&b'$') { + let sigil_index = start + 1; + if text_bytes.get(sigil_index) != Some(&(sigil as u8)) { return None; } - let name_start = dollar_index + 1; + let name_start = sigil_index + 1; let first_name_byte = text_bytes.get(name_start)?; if !is_mention_name_char(*first_name_byte) { return None; @@ -183,6 +187,7 @@ fn is_common_env_var(name: &str) -> bool { fn is_tool_path(path: &str) -> bool { path.starts_with("app://") || path.starts_with("mcp://") + || path.starts_with("plugin://") || path.starts_with("skill://") || path .rsplit(['/', '\\']) @@ -198,9 +203,9 @@ mod tests { #[test] fn decode_history_mentions_restores_visible_tokens() { let decoded = decode_history_mentions( - "Use [$figma](app://figma-1) and [$figma](/tmp/figma/SKILL.md).", + "Use [$figma](app://figma-1), [$sample](plugin://sample@test), and [$figma](/tmp/figma/SKILL.md).", ); - assert_eq!(decoded.text, "Use $figma and $figma."); + assert_eq!(decoded.text, "Use $figma, $sample, and $figma."); assert_eq!( decoded.mentions, vec![ @@ -208,6 +213,10 @@ mod tests { mention: "figma".to_string(), path: "app://figma-1".to_string(), }, + LinkedMention { + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, LinkedMention { mention: "figma".to_string(), path: "/tmp/figma/SKILL.md".to_string(), @@ -218,7 +227,7 @@ mod tests { #[test] fn encode_history_mentions_links_bound_mentions_in_order() { - let text = "$figma then $figma then $other"; + let text = "$figma then $sample then $figma then $other"; let encoded = encode_history_mentions( text, &[ @@ -226,6 +235,10 @@ mod tests { mention: "figma".to_string(), path: "app://figma-app".to_string(), }, + LinkedMention { + mention: "sample".to_string(), + path: "plugin://sample@test".to_string(), + }, LinkedMention { mention: "figma".to_string(), path: "/tmp/figma/SKILL.md".to_string(), @@ -234,7 +247,7 @@ mod tests { ); assert_eq!( encoded, - "[$figma](app://figma-app) then [$figma](/tmp/figma/SKILL.md) then $other" + "[$figma](app://figma-app) then [$sample](plugin://sample@test) then [$figma](/tmp/figma/SKILL.md) then $other" ); } }