From b9cd089d1fc76b690691437d9de5fa941d407b48 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 28 Jan 2026 19:51:58 -0800 Subject: [PATCH] [connectors] Support connectors part 2 - slash command and tui (#9728) - [x] Support `/apps` slash command to browse the apps in tui. - [x] Support inserting apps to prompt using `$`. - [x] Lots of simplification/renaming from connectors to apps. --- codex-rs/Cargo.lock | 3 + .../app-server-protocol/src/protocol/v2.rs | 16 + codex-rs/app-server/README.md | 72 ++++ .../app-server/src/codex_message_processor.rs | 16 +- .../app-server/tests/suite/v2/app_list.rs | 63 ++-- codex-rs/chatgpt/Cargo.toml | 2 + codex-rs/chatgpt/src/chatgpt_client.rs | 57 +--- codex-rs/chatgpt/src/connectors.rs | 312 ++++++++++++++---- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/codex.rs | 196 +++++++++-- codex-rs/core/src/connectors.rs | 101 +++--- codex-rs/core/src/features.rs | 14 +- codex-rs/core/src/features/legacy.rs | 4 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/mcp/mod.rs | 7 +- codex-rs/core/src/mcp_connection_manager.rs | 25 +- codex-rs/core/src/mentions.rs | 64 ++++ codex-rs/core/src/skills/injection.rs | 292 +++++++++++++--- codex-rs/core/src/token_data.rs | 25 ++ codex-rs/docs/protocol_v1.md | 9 + codex-rs/protocol/src/models.rs | 2 +- codex-rs/protocol/src/user_input.rs | 2 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 18 + codex-rs/tui/src/app_event.rs | 18 + codex-rs/tui/src/bottom_pane/app_link_view.rs | 163 +++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 238 +++++++++++-- codex-rs/tui/src/bottom_pane/command_popup.rs | 5 + codex-rs/tui/src/bottom_pane/mod.rs | 17 + codex-rs/tui/src/bottom_pane/skill_popup.rs | 80 +++-- .../tui/src/bottom_pane/slash_commands.rs | 6 + codex-rs/tui/src/chatwidget.rs | 278 +++++++++++++++- codex-rs/tui/src/chatwidget/skills.rs | 268 ++++++++++++++- codex-rs/tui/src/chatwidget/tests.rs | 5 + codex-rs/tui/src/skills_helpers.rs | 4 - codex-rs/tui/src/slash_command.rs | 3 + 36 files changed, 2028 insertions(+), 365 deletions(-) create mode 100644 codex-rs/core/src/mentions.rs create mode 100644 codex-rs/tui/src/bottom_pane/app_link_view.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c6b006c20..08cc85052 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1220,10 +1220,12 @@ dependencies = [ "codex-core", "codex-git", "codex-utils-cargo-bin", + "pretty_assertions", "serde", "serde_json", "tempfile", "tokio", + "urlencoding", ] [[package]] @@ -1886,6 +1888,7 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-backend-client", + "codex-chatgpt", "codex-cli", "codex-common", "codex-core", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fb234b71d..7b695d2c7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1003,6 +1003,8 @@ pub struct AppInfo { pub name: String, pub description: Option, pub logo_url: Option, + pub logo_url_dark: Option, + pub distribution_channel: Option, pub install_url: Option, #[serde(default)] pub is_accessible: bool, @@ -1897,6 +1899,10 @@ pub enum UserInput { name: String, path: PathBuf, }, + Mention { + name: String, + path: String, + }, } impl UserInput { @@ -1912,6 +1918,7 @@ impl UserInput { UserInput::Image { url } => CoreUserInput::Image { image_url: url }, UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, + UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, } } } @@ -1929,6 +1936,7 @@ impl From for UserInput { CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, + CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, _ => unreachable!("unsupported user input variant"), } } @@ -2732,6 +2740,10 @@ mod tests { name: "skill-creator".to_string(), path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), }, + CoreUserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, ], }); @@ -2754,6 +2766,10 @@ mod tests { name: "skill-creator".to_string(), path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), }, + UserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, ], } ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8195bd23c..caba8580a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -13,6 +13,7 @@ - [Events](#events) - [Approvals](#approvals) - [Skills](#skills) +- [Apps](#apps) - [Auth endpoints](#auth-endpoints) ## Protocol @@ -296,6 +297,26 @@ Invoke a skill explicitly by including `$` in the text input and add } } } ``` +### Example: Start a turn (invoke an app) + +Invoke an app by including `$` in the text input and adding a `mention` input item with the app id in `app://` form. + +```json +{ "method": "turn/start", "id": 34, "params": { + "threadId": "thr_123", + "input": [ + { "type": "text", "text": "$demo-app Summarize the latest updates." }, + { "type": "mention", "name": "Demo App", "path": "app://demo-app" } + ] +} } +{ "id": 34, "result": { "turn": { + "id": "turn_458", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + ### Example: Interrupt an active turn You can cancel a running Turn with `turn/interrupt`. @@ -583,6 +604,57 @@ To enable or disable a skill by path: } ``` +## Apps + +Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible. + +```json +{ "method": "app/list", "id": 50, "params": { + "cursor": null, + "limit": 50 +} } +{ "id": 50, "result": { + "data": [ + { + "id": "demo-app", + "name": "Demo App", + "description": "Example connector for documentation.", + "logoUrl": "https://example.com/demo-app.png", + "logoUrlDark": null, + "distributionChannel": null, + "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", + "isAccessible": true + } + ], + "nextCursor": null +} } +``` + +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. + +Example: + +``` +$demo-app Pull the latest updates from the team. +``` + +```json +{ + "method": "turn/start", + "id": 51, + "params": { + "threadId": "thread-1", + "input": [ + { + "type": "text", + "text": "$demo-app Pull the latest updates from the team." + }, + { "type": "mention", "name": "Demo App", "path": "app://demo-app" } + ] + } +} +``` + ## Auth endpoints The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c70f705a9..e86d840b9 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -13,7 +13,6 @@ use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; -use codex_app_server_protocol::AppInfo as ApiAppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::ArchiveConversationParams; @@ -3712,7 +3711,7 @@ impl CodexMessageProcessor { } }; - if !config.features.enabled(Feature::Connectors) { + if !config.features.enabled(Feature::Apps) { self.outgoing .send_response( request_id, @@ -3775,18 +3774,7 @@ impl CodexMessageProcessor { } let end = start.saturating_add(effective_limit).min(total); - let data = connectors[start..end] - .iter() - .cloned() - .map(|connector| ApiAppInfo { - id: connector.connector_id, - name: connector.connector_name, - description: connector.connector_description, - logo_url: connector.logo_url, - install_url: connector.install_url, - is_accessible: connector.is_accessible, - }) - .collect(); + let data = connectors[start..end].to_vec(); let next_cursor = if end < total { Some(end.to_string()) diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 4c3e22ffa..53221adae 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -13,14 +13,13 @@ use axum::extract::State; use axum::http::HeaderMap; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; -use axum::routing::post; +use axum::routing::get; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::auth::AuthCredentialsStoreMode; -use codex_core::connectors::ConnectorInfo; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; @@ -71,19 +70,23 @@ async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> { #[tokio::test] async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { let connectors = vec![ - ConnectorInfo { - connector_id: "alpha".to_string(), - connector_name: "Alpha".to_string(), - connector_description: Some("Alpha connector".to_string()), + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, install_url: None, is_accessible: false, }, - ConnectorInfo { - connector_id: "beta".to_string(), - connector_name: "beta".to_string(), - connector_description: None, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: None, is_accessible: false, }, @@ -127,6 +130,8 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { name: "Beta App".to_string(), description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, }, @@ -135,6 +140,8 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { name: "Alpha".to_string(), description: Some("Alpha connector".to_string()), logo_url: Some("https://example.com/alpha.png".to_string()), + logo_url_dark: None, + distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, }, @@ -150,19 +157,23 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { #[tokio::test] async fn list_apps_paginates_results() -> Result<()> { let connectors = vec![ - ConnectorInfo { - connector_id: "alpha".to_string(), - connector_name: "Alpha".to_string(), - connector_description: Some("Alpha connector".to_string()), + AppInfo { + id: "alpha".to_string(), + name: "Alpha".to_string(), + description: Some("Alpha connector".to_string()), logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: None, is_accessible: false, }, - ConnectorInfo { - connector_id: "beta".to_string(), - connector_name: "beta".to_string(), - connector_description: None, + AppInfo { + id: "beta".to_string(), + name: "beta".to_string(), + description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: None, is_accessible: false, }, @@ -206,6 +217,8 @@ async fn list_apps_paginates_results() -> Result<()> { name: "Beta App".to_string(), description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, }]; @@ -234,6 +247,8 @@ async fn list_apps_paginates_results() -> Result<()> { name: "Alpha".to_string(), description: Some("Alpha connector".to_string()), logo_url: None, + logo_url_dark: None, + distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, }]; @@ -289,13 +304,13 @@ impl ServerHandler for AppListMcpServer { } async fn start_apps_server( - connectors: Vec, + connectors: Vec, tools: Vec, ) -> Result<(String, JoinHandle<()>)> { let state = AppsServerState { expected_bearer: "Bearer chatgpt-token".to_string(), expected_account_id: "account-123".to_string(), - response: json!({ "connectors": connectors }), + response: json!({ "apps": connectors, "next_token": null }), }; let state = Arc::new(state); let tools = Arc::new(tools); @@ -313,7 +328,11 @@ async fn start_apps_server( ); let router = Router::new() - .route("/aip/connectors/list_accessible", post(list_connectors)) + .route("/connectors/directory/list", get(list_directory_connectors)) + .route( + "/connectors/directory/list_workspace", + get(list_directory_connectors), + ) .with_state(state) .nest_service("/api/codex/apps", mcp_service); @@ -324,7 +343,7 @@ async fn start_apps_server( Ok((format!("http://{addr}"), handle)) } -async fn list_connectors( +async fn list_directory_connectors( State(state): State>, headers: HeaderMap, ) -> Result { diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 70cd0aa5a..3c55878c8 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -17,6 +17,8 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } codex-git = { workspace = true } +urlencoding = { workspace = true } [dev-dependencies] +pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index b35238cde..b13be8c0c 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -5,13 +5,21 @@ use crate::chatgpt_token::get_chatgpt_token_data; use crate::chatgpt_token::init_chatgpt_token_from_auth; use anyhow::Context; -use serde::Serialize; use serde::de::DeserializeOwned; +use std::time::Duration; /// Make a GET request to the ChatGPT backend API. pub(crate) async fn chatgpt_get_request( config: &Config, path: String, +) -> anyhow::Result { + chatgpt_get_request_with_timeout(config, path, None).await +} + +pub(crate) async fn chatgpt_get_request_with_timeout( + config: &Config, + path: String, + timeout: Option, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) @@ -28,48 +36,17 @@ pub(crate) async fn chatgpt_get_request( anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`") }); - let response = client + let mut request = client .get(&url) .bearer_auth(&token.access_token) .header("chatgpt-account-id", account_id?) - .header("Content-Type", "application/json") - .send() - .await - .context("Failed to send request")?; - - if response.status().is_success() { - let result: T = response - .json() - .await - .context("Failed to parse JSON response")?; - Ok(result) - } else { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - anyhow::bail!("Request failed with status {status}: {body}") - } -} - -pub(crate) async fn chatgpt_post_request( - config: &Config, - access_token: &str, - account_id: &str, - path: &str, - payload: &P, -) -> anyhow::Result { - let chatgpt_base_url = &config.chatgpt_base_url; - let client = create_client(); - let url = format!("{chatgpt_base_url}{path}"); - - let response = client - .post(&url) - .bearer_auth(access_token) - .header("chatgpt-account-id", account_id) - .header("Content-Type", "application/json") - .json(payload) - .send() - .await - .context("Failed to send request")?; + .header("Content-Type", "application/json"); + + if let Some(timeout) = timeout { + request = request.timeout(timeout); + } + + let response = request.send().await.context("Failed to send request")?; if response.status().is_success() { let result: T = response diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 5d913ae16..c1a8f6535 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -1,43 +1,45 @@ +use std::collections::HashMap; + use codex_core::config::Config; use codex_core::features::Feature; use serde::Deserialize; -use serde::Serialize; +use std::time::Duration; -use crate::chatgpt_client::chatgpt_post_request; +use crate::chatgpt_client::chatgpt_get_request_with_timeout; use crate::chatgpt_token::get_chatgpt_token_data; use crate::chatgpt_token::init_chatgpt_token_from_auth; -pub use codex_core::connectors::ConnectorInfo; +pub use codex_core::connectors::AppInfo; pub use codex_core::connectors::connector_display_label; use codex_core::connectors::connector_install_url; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; use codex_core::connectors::merge_connectors; -#[derive(Debug, Serialize)] -struct ListConnectorsRequest { - principals: Vec, -} - -#[derive(Debug, Serialize)] -struct Principal { - #[serde(rename = "type")] - principal_type: PrincipalType, - id: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -enum PrincipalType { - User, -} - #[derive(Debug, Deserialize)] -struct ListConnectorsResponse { - connectors: Vec, +struct DirectoryListResponse { + apps: Vec, + #[serde(alias = "nextToken")] + next_token: Option, } -pub async fn list_connectors(config: &Config) -> anyhow::Result> { - if !config.features.enabled(Feature::Connectors) { +#[derive(Debug, Deserialize, Clone)] +struct DirectoryApp { + id: String, + name: String, + description: Option, + #[serde(alias = "logoUrl")] + logo_url: Option, + #[serde(alias = "logoUrlDark")] + logo_url_dark: Option, + #[serde(alias = "distributionChannel")] + distribution_channel: Option, + visibility: Option, +} + +const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); + +pub async fn list_connectors(config: &Config) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { return Ok(Vec::new()); } let (connectors_result, accessible_result) = tokio::join!( @@ -46,11 +48,12 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result anyhow::Result> { - if !config.features.enabled(Feature::Connectors) { +pub async fn list_all_connectors(config: &Config) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { return Ok(Vec::new()); } init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) @@ -58,56 +61,149 @@ pub async fn list_all_connectors(config: &Config) -> anyhow::Result>(); for connector in &mut connectors { let install_url = match connector.install_url.take() { Some(install_url) => install_url, - None => connector_install_url(&connector.connector_name, &connector.connector_id), + None => connector_install_url(&connector.name, &connector.id), }; - connector.connector_name = - normalize_connector_name(&connector.connector_name, &connector.connector_id); - connector.connector_description = - normalize_connector_value(connector.connector_description.as_deref()); + connector.name = normalize_connector_name(&connector.name, &connector.id); + connector.description = normalize_connector_value(connector.description.as_deref()); connector.install_url = Some(install_url); connector.is_accessible = false; } connectors.sort_by(|left, right| { - left.connector_name - .cmp(&right.connector_name) - .then_with(|| left.connector_id.cmp(&right.connector_id)) + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) }); Ok(connectors) } +async fn list_directory_connectors(config: &Config) -> anyhow::Result> { + let mut apps = Vec::new(); + let mut next_token: Option = None; + loop { + let path = match next_token.as_deref() { + Some(token) => { + let encoded_token = urlencoding::encode(token); + format!("/connectors/directory/list?tier=categorized&token={encoded_token}") + } + None => "/connectors/directory/list?tier=categorized".to_string(), + }; + let response: DirectoryListResponse = + chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT)) + .await?; + apps.extend( + response + .apps + .into_iter() + .filter(|app| !is_hidden_directory_app(app)), + ); + next_token = response + .next_token + .map(|token| token.trim().to_string()) + .filter(|token| !token.is_empty()); + if next_token.is_none() { + break; + } + } + Ok(apps) +} + +async fn list_workspace_connectors(config: &Config) -> anyhow::Result> { + let response: anyhow::Result = chatgpt_get_request_with_timeout( + config, + "/connectors/directory/list_workspace".to_string(), + Some(DIRECTORY_CONNECTORS_TIMEOUT), + ) + .await; + match response { + Ok(response) => Ok(response + .apps + .into_iter() + .filter(|app| !is_hidden_directory_app(app)) + .collect()), + Err(_) => Ok(Vec::new()), + } +} + +fn merge_directory_apps(apps: Vec) -> Vec { + let mut merged: HashMap = HashMap::new(); + for app in apps { + if let Some(existing) = merged.get_mut(&app.id) { + merge_directory_app(existing, app); + } else { + merged.insert(app.id.clone(), app); + } + } + merged.into_values().collect() +} + +fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) { + let DirectoryApp { + id: _, + name, + description, + logo_url, + logo_url_dark, + distribution_channel, + visibility: _, + } = incoming; + + let incoming_name_is_empty = name.trim().is_empty(); + if existing.name.trim().is_empty() && !incoming_name_is_empty { + existing.name = name; + } + + let incoming_description_present = description + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + let existing_description_present = existing + .description + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + if !existing_description_present && incoming_description_present { + existing.description = description; + } + + if existing.logo_url.is_none() && logo_url.is_some() { + existing.logo_url = logo_url; + } + if existing.logo_url_dark.is_none() && logo_url_dark.is_some() { + existing.logo_url_dark = logo_url_dark; + } + if existing.distribution_channel.is_none() && distribution_channel.is_some() { + existing.distribution_channel = distribution_channel; + } +} + +fn is_hidden_directory_app(app: &DirectoryApp) -> bool { + matches!(app.visibility.as_deref(), Some("HIDDEN")) +} + +fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { + AppInfo { + id: app.id, + name: app.name, + description: app.description, + logo_url: app.logo_url, + logo_url_dark: app.logo_url_dark, + distribution_channel: app.distribution_channel, + install_url: None, + is_accessible: false, + } +} + fn normalize_connector_name(name: &str, connector_id: &str) -> String { let trimmed = name.trim(); if trimmed.is_empty() { @@ -123,3 +219,87 @@ fn normalize_connector_value(value: Option<&str>) -> Option { .filter(|value| !value.is_empty()) .map(str::to_string) } + +const ALLOWED_APPS_SDK_APPS: &[&str] = &["asdk_app_69781557cc1481919cf5e9824fa2e792"]; +const DISALLOWED_CONNECTOR_IDS: &[&str] = &["asdk_app_6938a94a61d881918ef32cb999ff937c"]; +const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; + +fn filter_disallowed_connectors(connectors: Vec) -> Vec { + // TODO: Support Apps SDK connectors. + connectors + .into_iter() + .filter(is_connector_allowed) + .collect() +} + +fn is_connector_allowed(connector: &AppInfo) -> bool { + let connector_id = connector.id.as_str(); + if connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) + || DISALLOWED_CONNECTOR_IDS.contains(&connector_id) + { + return false; + } + if connector_id.starts_with("asdk_app_") { + return ALLOWED_APPS_SDK_APPS.contains(&connector_id); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn app(id: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + is_accessible: false, + } + } + + #[test] + fn filters_internal_asdk_connectors() { + let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]); + assert_eq!(filtered, vec![app("alpha")]); + } + + #[test] + fn allows_whitelisted_asdk_connectors() { + let filtered = filter_disallowed_connectors(vec![ + app("asdk_app_69781557cc1481919cf5e9824fa2e792"), + app("beta"), + ]); + assert_eq!( + filtered, + vec![ + app("asdk_app_69781557cc1481919cf5e9824fa2e792"), + app("beta") + ] + ); + } + + #[test] + fn filters_openai_connectors() { + let filtered = filter_disallowed_connectors(vec![ + app("connector_openai_foo"), + app("connector_openai_bar"), + app("gamma"), + ]); + assert_eq!(filtered, vec![app("gamma")]); + } + + #[test] + fn filters_disallowed_connector_ids() { + let filtered = filter_disallowed_connectors(vec![ + app("asdk_app_6938a94a61d881918ef32cb999ff937c"), + app("delta"), + ]); + assert_eq!(filtered, vec![app("delta")]); + } +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ea6ec9e87..717eddc61 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -144,6 +144,9 @@ "apply_patch_freeform": { "type": "boolean" }, + "apps": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -1165,6 +1168,9 @@ "apply_patch_freeform": { "type": "boolean" }, + "apps": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 70e52eb06..dbc2d5c63 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -118,6 +118,10 @@ use crate::mcp::effective_mcp_servers; use crate::mcp::maybe_prompt_and_install_mcp_dependencies; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; +use crate::mentions::build_connector_slug_counts; +use crate::mentions::build_skill_name_counts; +use crate::mentions::collect_explicit_app_paths; +use crate::mentions::collect_tool_mentions_from_messages; use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageContentDeltaEvent; @@ -163,6 +167,9 @@ use crate::skills::SkillMetadata; use crate::skills::SkillsManager; use crate::skills::build_skill_injections; use crate::skills::collect_explicit_skill_mentions; +use crate::skills::injection::ToolMentionKind; +use crate::skills::injection::app_id_from_path; +use crate::skills::injection::tool_kind_for_path; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -2189,7 +2196,7 @@ impl Session { let config = self.get_config().await; let mcp_servers = with_codex_apps_mcp( mcp_servers, - self.features.enabled(Feature::Connectors), + self.features.enabled(Feature::Apps), auth.as_ref(), config.as_ref(), ); @@ -3166,9 +3173,38 @@ pub(crate) async fn run_turn( .await, ); + let (skill_name_counts, skill_name_counts_lower) = skills_outcome.as_ref().map_or_else( + || (HashMap::new(), HashMap::new()), + |outcome| build_skill_name_counts(&outcome.skills, &outcome.disabled_paths), + ); + let connector_slug_counts = if turn_context.client.config().features.enabled(Feature::Apps) { + let mcp_tools = match sess + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .or_cancel(&cancellation_token) + .await + { + Ok(mcp_tools) => mcp_tools, + Err(_) => return None, + }; + let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + build_connector_slug_counts(&connectors) + } else { + HashMap::new() + }; let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| { - collect_explicit_skill_mentions(&input, &outcome.skills, &outcome.disabled_paths) + collect_explicit_skill_mentions( + &input, + &outcome.skills, + &outcome.disabled_paths, + &skill_name_counts, + &connector_slug_counts, + ) }); + let explicit_app_paths = collect_explicit_app_paths(&input); maybe_prompt_and_install_mcp_dependencies( sess.as_ref(), @@ -3234,12 +3270,17 @@ pub(crate) async fn run_turn( }) .map(|user_message| user_message.message()) .collect::>(); + let tool_selection = SamplingRequestToolSelection { + explicit_app_paths: &explicit_app_paths, + skill_name_counts_lower: &skill_name_counts_lower, + }; match run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), &mut client_session, sampling_request_input, + tool_selection, cancellation_token.child_token(), ) .await @@ -3314,40 +3355,79 @@ async fn run_auto_compact(sess: &Arc, turn_context: &Arc) } fn filter_connectors_for_input( - connectors: Vec, + connectors: Vec, input: &[ResponseItem], -) -> Vec { + explicit_app_paths: &[String], + skill_name_counts_lower: &HashMap, +) -> Vec { let user_messages = collect_user_messages(input); - if user_messages.is_empty() { + if user_messages.is_empty() && explicit_app_paths.is_empty() { return Vec::new(); } + let mentions = collect_tool_mentions_from_messages(&user_messages); + let mention_names_lower = mentions + .plain_names + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect::>(); + + let connector_slug_counts = build_connector_slug_counts(&connectors); + let mut allowed_connector_ids: HashSet = HashSet::new(); + for path in explicit_app_paths + .iter() + .chain(mentions.paths.iter()) + .filter(|path| tool_kind_for_path(path) == ToolMentionKind::App) + { + if let Some(connector_id) = app_id_from_path(path) { + allowed_connector_ids.insert(connector_id.to_string()); + } + } + connectors .into_iter() - .filter(|connector| connector_inserted_in_messages(connector, &user_messages)) + .filter(|connector| { + connector_inserted_in_messages( + connector, + &mention_names_lower, + &allowed_connector_ids, + &connector_slug_counts, + skill_name_counts_lower, + ) + }) .collect() } fn connector_inserted_in_messages( - connector: &connectors::ConnectorInfo, - user_messages: &[String], + connector: &connectors::AppInfo, + mention_names_lower: &HashSet, + allowed_connector_ids: &HashSet, + connector_slug_counts: &HashMap, + skill_name_counts_lower: &HashMap, ) -> bool { - let label = connectors::connector_display_label(connector); - let needle = label.to_lowercase(); - let legacy = format!("{label} connector").to_lowercase(); - user_messages.iter().any(|message| { - let message = message.to_lowercase(); - message.contains(&needle) || message.contains(&legacy) - }) + if allowed_connector_ids.contains(&connector.id) { + return true; + } + + let mention_slug = connectors::connector_mention_slug(connector); + let connector_count = connector_slug_counts + .get(&mention_slug) + .copied() + .unwrap_or(0); + let skill_count = skill_name_counts_lower + .get(&mention_slug) + .copied() + .unwrap_or(0); + connector_count == 1 && skill_count == 0 && mention_names_lower.contains(&mention_slug) } fn filter_codex_apps_mcp_tools( mut mcp_tools: HashMap, - connectors: &[connectors::ConnectorInfo], + connectors: &[connectors::AppInfo], ) -> HashMap { let allowed: HashSet<&str> = connectors .iter() - .map(|connector| connector.connector_id.as_str()) + .map(|connector| connector.id.as_str()) .collect(); mcp_tools.retain(|_, tool| { @@ -3367,6 +3447,11 @@ fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Op tool.connector_id.as_deref() } +struct SamplingRequestToolSelection<'a> { + explicit_app_paths: &'a [String], + skill_name_counts_lower: &'a HashMap, +} + #[instrument(level = "trace", skip_all, fields( @@ -3381,6 +3466,7 @@ async fn run_sampling_request( turn_diff_tracker: SharedTurnDiffTracker, client_session: &mut ModelClientSession, input: Vec, + tool_selection: SamplingRequestToolSelection<'_>, cancellation_token: CancellationToken, ) -> CodexResult { let mut mcp_tools = sess @@ -3391,14 +3477,14 @@ async fn run_sampling_request( .list_all_tools() .or_cancel(&cancellation_token) .await?; - let connectors_for_tools = if turn_context - .client - .config() - .features - .enabled(Feature::Connectors) - { + let connectors_for_tools = if turn_context.client.config().features.enabled(Feature::Apps) { let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); - Some(filter_connectors_for_input(connectors, &input)) + Some(filter_connectors_for_input( + connectors, + &input, + tool_selection.explicit_app_paths, + tool_selection.skill_name_counts_lower, + )) } else { None }; @@ -3822,6 +3908,7 @@ mod tests { use crate::tools::handlers::UnifiedExecHandler; use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; + use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AuthMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -3843,6 +3930,30 @@ mod tests { expects_apply_patch_instructions: bool, } + fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + end_turn: None, + } + } + + fn make_connector(id: &str, name: &str) -> AppInfo { + AppInfo { + id: id.to_string(), + name: name.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + is_accessible: true, + } + } + #[tokio::test] async fn get_base_instructions_no_user_content() { let prompt_with_apply_patch_instructions = @@ -3909,6 +4020,43 @@ mod tests { } } + #[test] + fn filter_connectors_for_input_skips_duplicate_slug_mentions() { + let connectors = vec![ + make_connector("one", "Foo Bar"), + make_connector("two", "Foo-Bar"), + ]; + let input = vec![user_message("use $foo-bar")]; + let explicit_app_paths = Vec::new(); + let skill_name_counts_lower = HashMap::new(); + + let selected = filter_connectors_for_input( + connectors, + &input, + &explicit_app_paths, + &skill_name_counts_lower, + ); + + assert_eq!(selected, Vec::new()); + } + + #[test] + fn filter_connectors_for_input_skips_when_skill_name_conflicts() { + let connectors = vec![make_connector("one", "Todoist")]; + let input = vec![user_message("use $todoist")]; + let explicit_app_paths = Vec::new(); + let skill_name_counts_lower = HashMap::from([("todoist".to_string(), 1)]); + + let selected = filter_connectors_for_input( + connectors, + &input, + &explicit_app_paths, + &skill_name_counts_lower, + ); + + assert_eq!(selected, Vec::new()); + } + #[tokio::test] async fn reconstruct_history_matches_live_compactions() { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index fa97dacc3..edf3c63aa 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -3,9 +3,8 @@ use std::env; use std::path::PathBuf; use async_channel::unbounded; +pub use codex_app_server_protocol::AppInfo; use codex_protocol::protocol::SandboxPolicy; -use serde::Deserialize; -use serde::Serialize; use tokio_util::sync::CancellationToken; use crate::AuthManager; @@ -15,28 +14,13 @@ use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; +use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; use crate::mcp_connection_manager::McpConnectionManager; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectorInfo { - #[serde(rename = "id")] - pub connector_id: String, - #[serde(rename = "name")] - pub connector_name: String, - #[serde(default, rename = "description")] - pub connector_description: Option, - #[serde(default, rename = "logo_url")] - pub logo_url: Option, - #[serde(default, rename = "install_url")] - pub install_url: Option, - #[serde(default)] - pub is_accessible: bool, -} - pub async fn list_accessible_connectors_from_mcp_tools( config: &Config, -) -> anyhow::Result> { - if !config.features.enabled(Feature::Connectors) { +) -> anyhow::Result> { + if !config.features.enabled(Feature::Apps) { return Ok(Vec::new()); } @@ -72,6 +56,13 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) .await; + if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) { + let timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT); + mcp_connection_manager + .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) + .await; + } + let tools = mcp_connection_manager.list_all_tools().await; cancel_token.cancel(); @@ -86,13 +77,17 @@ fn auth_manager_from_config(config: &Config) -> std::sync::Arc { ) } -pub fn connector_display_label(connector: &ConnectorInfo) -> String { - format_connector_label(&connector.connector_name, &connector.connector_id) +pub fn connector_display_label(connector: &AppInfo) -> String { + format_connector_label(&connector.name, &connector.id) +} + +pub fn connector_mention_slug(connector: &AppInfo) -> String { + connector_name_slug(&connector_display_label(connector)) } pub(crate) fn accessible_connectors_from_mcp_tools( mcp_tools: &HashMap, -) -> Vec { +) -> Vec { let tools = mcp_tools.values().filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; @@ -105,34 +100,37 @@ pub(crate) fn accessible_connectors_from_mcp_tools( } pub fn merge_connectors( - connectors: Vec, - accessible_connectors: Vec, -) -> Vec { - let mut merged: HashMap = connectors + connectors: Vec, + accessible_connectors: Vec, +) -> Vec { + let mut merged: HashMap = connectors .into_iter() .map(|mut connector| { connector.is_accessible = false; - (connector.connector_id.clone(), connector) + (connector.id.clone(), connector) }) .collect(); for mut connector in accessible_connectors { connector.is_accessible = true; - let connector_id = connector.connector_id.clone(); + let connector_id = connector.id.clone(); if let Some(existing) = merged.get_mut(&connector_id) { existing.is_accessible = true; - if existing.connector_name == existing.connector_id - && connector.connector_name != connector.connector_id - { - existing.connector_name = connector.connector_name; + if existing.name == existing.id && connector.name != connector.id { + existing.name = connector.name; } - if existing.connector_description.is_none() && connector.connector_description.is_some() - { - existing.connector_description = connector.connector_description; + if existing.description.is_none() && connector.description.is_some() { + existing.description = connector.description; } if existing.logo_url.is_none() && connector.logo_url.is_some() { existing.logo_url = connector.logo_url; } + if existing.logo_url_dark.is_none() && connector.logo_url_dark.is_some() { + existing.logo_url_dark = connector.logo_url_dark; + } + if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() { + existing.distribution_channel = connector.distribution_channel; + } } else { merged.insert(connector_id, connector); } @@ -141,23 +139,20 @@ pub fn merge_connectors( let mut merged = merged.into_values().collect::>(); for connector in &mut merged { if connector.install_url.is_none() { - connector.install_url = Some(connector_install_url( - &connector.connector_name, - &connector.connector_id, - )); + connector.install_url = Some(connector_install_url(&connector.name, &connector.id)); } } merged.sort_by(|left, right| { right .is_accessible .cmp(&left.is_accessible) - .then_with(|| left.connector_name.cmp(&right.connector_name)) - .then_with(|| left.connector_id.cmp(&right.connector_id)) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) }); merged } -fn collect_accessible_connectors(tools: I) -> Vec +fn collect_accessible_connectors(tools: I) -> Vec where I: IntoIterator)>, { @@ -172,14 +167,16 @@ where connectors.insert(connector_id, connector_name); } } - let mut accessible: Vec = connectors + let mut accessible: Vec = connectors .into_iter() - .map(|(connector_id, connector_name)| ConnectorInfo { - install_url: Some(connector_install_url(&connector_name, &connector_id)), - connector_id, - connector_name, - connector_description: None, + .map(|(connector_id, connector_name)| AppInfo { + id: connector_id.clone(), + name: connector_name.clone(), + description: None, logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some(connector_install_url(&connector_name, &connector_id)), is_accessible: true, }) .collect(); @@ -187,8 +184,8 @@ where right .is_accessible .cmp(&left.is_accessible) - .then_with(|| left.connector_name.cmp(&right.connector_name)) - .then_with(|| left.connector_id.cmp(&right.connector_id)) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) }); accessible } @@ -205,7 +202,7 @@ pub fn connector_install_url(name: &str, connector_id: &str) -> String { format!("https://chatgpt.com/apps/{slug}/{connector_id}") } -fn connector_name_slug(name: &str) -> String { +pub fn connector_name_slug(name: &str) -> String { let mut normalized = String::with_capacity(name.len()); for character in name.chars() { if character.is_ascii_alphanumeric() { diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index bedf67c4a..c7cd41114 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -111,8 +111,8 @@ pub enum Feature { EnableRequestCompression, /// Enable collab tools. Collab, - /// Enable connectors (apps). - Connectors, + /// Enable apps. + Apps, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. @@ -518,9 +518,13 @@ pub const FEATURES: &[FeatureSpec] = &[ default_enabled: false, }, FeatureSpec { - id: Feature::Connectors, - key: "connectors", - stage: Stage::UnderDevelopment, + id: Feature::Apps, + key: "apps", + stage: Stage::Experimental { + name: "Apps", + menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.", + announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!", + }, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 2b5a9e7fe..0c0f75714 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -9,6 +9,10 @@ struct Alias { } const ALIASES: &[Alias] = &[ + Alias { + legacy_key: "connectors", + feature: Feature::Apps, + }, Alias { legacy_key: "enable_experimental_windows_sandbox", feature: Feature::WindowsSandbox, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index eaf25d14e..ab51a201d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -42,6 +42,7 @@ pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; mod mcp_tool_call; +mod mentions; mod message_history; mod model_provider_info; pub mod parse_command; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index fa1de0f04..12c61ddaf 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -6,6 +6,7 @@ pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies; use std::collections::HashMap; use std::env; use std::path::PathBuf; +use std::time::Duration; use async_channel::unbounded; use codex_protocol::protocol::McpListToolsResponseEvent; @@ -25,7 +26,7 @@ use crate::mcp_connection_manager::SandboxState; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; const MCP_TOOL_NAME_DELIMITER: &str = "__"; -pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps_mcp"; +pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; fn codex_apps_mcp_bearer_token_env_var() -> Option { @@ -97,7 +98,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc }, enabled: true, disabled_reason: None, - startup_timeout_sec: None, + startup_timeout_sec: Some(Duration::from_secs(30)), tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, @@ -128,7 +129,7 @@ pub(crate) fn effective_mcp_servers( ) -> HashMap { with_codex_apps_mcp( config.mcp_servers.get().clone(), - config.features.enabled(Feature::Connectors), + config.features.enabled(Feature::Apps), auth, config, ) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 9462f745b..15305f4b0 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::McpAuthStatusEntry; use anyhow::Context; use anyhow::Result; @@ -436,13 +437,33 @@ impl McpConnectionManager { .await } + pub(crate) async fn wait_for_server_ready(&self, server_name: &str, timeout: Duration) -> bool { + let Some(async_managed_client) = self.clients.get(server_name) else { + return false; + }; + + match tokio::time::timeout(timeout, async_managed_client.client()).await { + Ok(Ok(_)) => true, + Ok(Err(_)) | Err(_) => false, + } + } + /// Returns a single map that contains all tools. Each key is the /// fully-qualified name for the tool. #[instrument(level = "trace", skip_all)] pub async fn list_all_tools(&self) -> HashMap { let mut tools = HashMap::new(); - for managed_client in self.clients.values() { - if let Ok(client) = managed_client.client().await { + for (server_name, managed_client) in &self.clients { + let client = if server_name == CODEX_APPS_MCP_SERVER_NAME { + // Avoid blocking on codex_apps_mcp startup; use tools only when ready. + match managed_client.client.clone().now_or_never() { + Some(Ok(client)) => Some(client), + _ => None, + } + } else { + managed_client.client().await.ok() + }; + if let Some(client) = client { tools.extend(qualify_tools(filter_tools( client.tools, client.tool_filter, diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/mentions.rs new file mode 100644 index 000000000..2f39c10d2 --- /dev/null +++ b/codex-rs/core/src/mentions.rs @@ -0,0 +1,64 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; + +use codex_protocol::user_input::UserInput; + +use crate::connectors; +use crate::skills::SkillMetadata; +use crate::skills::injection::extract_tool_mentions; + +pub(crate) struct CollectedToolMentions { + pub(crate) plain_names: HashSet, + pub(crate) paths: HashSet, +} + +pub(crate) fn collect_tool_mentions_from_messages(messages: &[String]) -> CollectedToolMentions { + let mut plain_names = HashSet::new(); + let mut paths = HashSet::new(); + for message in messages { + let mentions = extract_tool_mentions(message); + plain_names.extend(mentions.plain_names().map(str::to_string)); + paths.extend(mentions.paths().map(str::to_string)); + } + CollectedToolMentions { plain_names, paths } +} + +pub(crate) fn collect_explicit_app_paths(input: &[UserInput]) -> Vec { + input + .iter() + .filter_map(|item| match item { + UserInput::Mention { path, .. } => Some(path.clone()), + _ => None, + }) + .collect() +} + +pub(crate) fn build_skill_name_counts( + skills: &[SkillMetadata], + disabled_paths: &HashSet, +) -> (HashMap, HashMap) { + let mut exact_counts: HashMap = HashMap::new(); + let mut lower_counts: HashMap = HashMap::new(); + for skill in skills { + if disabled_paths.contains(&skill.path) { + continue; + } + *exact_counts.entry(skill.name.clone()).or_insert(0) += 1; + *lower_counts + .entry(skill.name.to_ascii_lowercase()) + .or_insert(0) += 1; + } + (exact_counts, lower_counts) +} + +pub(crate) fn build_connector_slug_counts( + connectors: &[connectors::AppInfo], +) -> HashMap { + let mut counts: HashMap = HashMap::new(); + for connector in connectors { + let slug = connectors::connector_mention_slug(connector); + *counts.entry(slug).or_insert(0) += 1; + } + counts +} diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index 65ec4dd51..57de457e9 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -67,7 +68,8 @@ fn emit_skill_injected_metric(otel: Option<&OtelManager>, skill: &SkillMetadata, /// Collect explicitly mentioned skills from `$name` text mentions. /// /// Text inputs are scanned once to extract `$skill-name` tokens, then we iterate `skills` -/// in their existing order to preserve prior ordering semantics. +/// in their existing order to preserve prior ordering semantics. Explicit links are +/// resolved by path and plain names are only used when the match is unambiguous. /// /// Complexity: `O(S + T + N_t * S)` time, `O(S)` space, where: /// `S` = number of skills, `T` = total text length, `N_t` = number of text inputs. @@ -75,17 +77,24 @@ pub(crate) fn collect_explicit_skill_mentions( inputs: &[UserInput], skills: &[SkillMetadata], disabled_paths: &HashSet, + skill_name_counts: &HashMap, + connector_slug_counts: &HashMap, ) -> Vec { + let selection_context = SkillSelectionContext { + skills, + disabled_paths, + skill_name_counts, + connector_slug_counts, + }; let mut selected: Vec = Vec::new(); let mut seen_names: HashSet = HashSet::new(); let mut seen_paths: HashSet = HashSet::new(); for input in inputs { if let UserInput::Text { text, .. } = input { - let mentioned_names = extract_skill_mentions(text); + let mentioned_names = extract_tool_mentions(text); select_skills_from_mentions( - skills, - disabled_paths, + &selection_context, &mentioned_names, &mut seen_names, &mut seen_paths, @@ -97,36 +106,95 @@ pub(crate) fn collect_explicit_skill_mentions( selected } -struct SkillMentions<'a> { - names: HashSet<&'a str>, - paths: HashSet<&'a str>, +struct SkillSelectionContext<'a> { + skills: &'a [SkillMetadata], + disabled_paths: &'a HashSet, + skill_name_counts: &'a HashMap, + connector_slug_counts: &'a HashMap, } -impl<'a> SkillMentions<'a> { +pub(crate) struct ToolMentions<'a> { + names: HashSet<&'a str>, + paths: HashSet<&'a str>, + plain_names: HashSet<&'a str>, +} + +impl<'a> ToolMentions<'a> { fn is_empty(&self) -> bool { self.names.is_empty() && self.paths.is_empty() } + + pub(crate) fn plain_names(&self) -> impl Iterator + '_ { + self.plain_names.iter().copied() + } + + pub(crate) fn paths(&self) -> impl Iterator + '_ { + self.paths.iter().copied() + } } -/// Extract `$skill-name` mentions from a single text input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToolMentionKind { + App, + Mcp, + Skill, + Other, +} + +const APP_PATH_PREFIX: &str = "app://"; +const MCP_PATH_PREFIX: &str = "mcp://"; +const SKILL_PATH_PREFIX: &str = "skill://"; +const SKILL_FILENAME: &str = "SKILL.md"; + +pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind { + if path.starts_with(APP_PATH_PREFIX) { + ToolMentionKind::App + } else if path.starts_with(MCP_PATH_PREFIX) { + ToolMentionKind::Mcp + } else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) { + ToolMentionKind::Skill + } else { + ToolMentionKind::Other + } +} + +fn is_skill_filename(path: &str) -> bool { + let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path); + file_name.eq_ignore_ascii_case(SKILL_FILENAME) +} + +pub(crate) fn app_id_from_path(path: &str) -> Option<&str> { + path.strip_prefix(APP_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) +} + +/// Extract `$tool-name` mentions from a single text input. /// -/// Supports explicit resource links in the form `[$skill-name](resource path)`. When a +/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a /// resource path is present, it is captured for exact path matching while also tracking /// the name for fallback matching. -fn extract_skill_mentions(text: &str) -> SkillMentions<'_> { +pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { let text_bytes = text.as_bytes(); let mut mentioned_names: HashSet<&str> = HashSet::new(); let mut mentioned_paths: HashSet<&str> = HashSet::new(); + let mut plain_names: HashSet<&str> = HashSet::new(); let mut index = 0; while index < text_bytes.len() { let byte = text_bytes[index]; if byte == b'[' && let Some((name, path, end_index)) = - parse_linked_skill_mention(text, text_bytes, index) + parse_linked_tool_mention(text, text_bytes, index) { if !is_common_env_var(name) { - mentioned_names.insert(name); + let kind = tool_kind_for_path(path); + if !matches!(kind, ToolMentionKind::App | ToolMentionKind::Mcp) { + mentioned_names.insert(name); + } mentioned_paths.insert(path); } index = end_index; @@ -143,14 +211,14 @@ fn extract_skill_mentions(text: &str) -> SkillMentions<'_> { index += 1; continue; }; - if !is_skill_name_char(*first_name_byte) { + if !is_mention_name_char(*first_name_byte) { index += 1; continue; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) - && is_skill_name_char(*next_byte) + && is_mention_name_char(*next_byte) { name_end += 1; } @@ -158,21 +226,22 @@ fn extract_skill_mentions(text: &str) -> SkillMentions<'_> { let name = &text[name_start..name_end]; if !is_common_env_var(name) { mentioned_names.insert(name); + plain_names.insert(name); } index = name_end; } - SkillMentions { + ToolMentions { names: mentioned_names, paths: mentioned_paths, + plain_names, } } /// Select mentioned skills while preserving the order of `skills`. fn select_skills_from_mentions( - skills: &[SkillMetadata], - disabled_paths: &HashSet, - mentions: &SkillMentions<'_>, + selection_context: &SkillSelectionContext<'_>, + mentions: &ToolMentions<'_>, seen_names: &mut HashSet, seen_paths: &mut HashSet, selected: &mut Vec, @@ -181,32 +250,65 @@ fn select_skills_from_mentions( return; } - for skill in skills { - if disabled_paths.contains(&skill.path) || seen_paths.contains(&skill.path) { + let mention_skill_paths: HashSet<&str> = mentions + .paths() + .filter(|path| { + !matches!( + tool_kind_for_path(path), + ToolMentionKind::App | ToolMentionKind::Mcp + ) + }) + .map(normalize_skill_path) + .collect(); + + for skill in selection_context.skills { + if selection_context.disabled_paths.contains(&skill.path) + || seen_paths.contains(&skill.path) + { continue; } let path_str = skill.path.to_string_lossy(); - if mentions.paths.contains(path_str.as_ref()) { + if mention_skill_paths.contains(path_str.as_ref()) { seen_paths.insert(skill.path.clone()); seen_names.insert(skill.name.clone()); selected.push(skill.clone()); } } - for skill in skills { - if disabled_paths.contains(&skill.path) || seen_paths.contains(&skill.path) { + for skill in selection_context.skills { + if selection_context.disabled_paths.contains(&skill.path) + || seen_paths.contains(&skill.path) + { continue; } - if mentions.names.contains(skill.name.as_str()) && seen_names.insert(skill.name.clone()) { + if !mentions.plain_names.contains(skill.name.as_str()) { + continue; + } + + let skill_count = selection_context + .skill_name_counts + .get(skill.name.as_str()) + .copied() + .unwrap_or(0); + let connector_count = selection_context + .connector_slug_counts + .get(&skill.name.to_ascii_lowercase()) + .copied() + .unwrap_or(0); + if skill_count != 1 || connector_count != 0 { + continue; + } + + if seen_names.insert(skill.name.clone()) { seen_paths.insert(skill.path.clone()); selected.push(skill.clone()); } } } -fn parse_linked_skill_mention<'a>( +fn parse_linked_tool_mention<'a>( text: &'a str, text_bytes: &[u8], start: usize, @@ -218,13 +320,13 @@ fn parse_linked_skill_mention<'a>( let name_start = dollar_index + 1; let first_name_byte = text_bytes.get(name_start)?; - if !is_skill_name_char(*first_name_byte) { + if !is_mention_name_char(*first_name_byte) { return None; } let mut name_end = name_start + 1; while let Some(next_byte) = text_bytes.get(name_end) - && is_skill_name_char(*next_byte) + && is_mention_name_char(*next_byte) { name_end += 1; } @@ -304,7 +406,7 @@ fn text_mentions_skill(text: &str, skill_name: &str) -> bool { let after_index = name_start + skill_bytes.len(); let after = text_bytes.get(after_index).copied(); - if after.is_none_or(|b| !is_skill_name_char(b)) { + if after.is_none_or(|b| !is_mention_name_char(b)) { return true; } } @@ -312,7 +414,7 @@ fn text_mentions_skill(text: &str, skill_name: &str) -> bool { false } -fn is_skill_name_char(byte: u8) -> bool { +fn is_mention_name_char(byte: u8) -> bool { matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') } @@ -320,6 +422,7 @@ fn is_skill_name_char(byte: u8) -> bool { mod tests { use super::*; use pretty_assertions::assert_eq; + use std::collections::HashMap; use std::collections::HashSet; fn make_skill(name: &str, path: &str) -> SkillMetadata { @@ -339,11 +442,41 @@ mod tests { } fn assert_mentions(text: &str, expected_names: &[&str], expected_paths: &[&str]) { - let mentions = extract_skill_mentions(text); + let mentions = extract_tool_mentions(text); assert_eq!(mentions.names, set(expected_names)); assert_eq!(mentions.paths, set(expected_paths)); } + fn build_skill_name_counts( + skills: &[SkillMetadata], + disabled_paths: &HashSet, + ) -> HashMap { + let mut counts = HashMap::new(); + for skill in skills { + if disabled_paths.contains(&skill.path) { + continue; + } + *counts.entry(skill.name.clone()).or_insert(0) += 1; + } + counts + } + + fn collect_mentions( + inputs: &[UserInput], + skills: &[SkillMetadata], + disabled_paths: &HashSet, + connector_slug_counts: &HashMap, + ) -> Vec { + let skill_name_counts = build_skill_name_counts(skills, disabled_paths); + collect_explicit_skill_mentions( + inputs, + skills, + disabled_paths, + &skill_name_counts, + connector_slug_counts, + ) + } + #[test] fn text_mentions_skill_requires_exact_boundary() { assert_eq!( @@ -386,7 +519,7 @@ mod tests { } #[test] - fn extract_skill_mentions_handles_plain_and_linked_mentions() { + fn extract_tool_mentions_handles_plain_and_linked_mentions() { assert_mentions( "use $alpha and [$beta](/tmp/beta)", &["alpha", "beta"], @@ -395,26 +528,26 @@ mod tests { } #[test] - fn extract_skill_mentions_skips_common_env_vars() { + fn extract_tool_mentions_skips_common_env_vars() { assert_mentions("use $PATH and $alpha", &["alpha"], &[]); assert_mentions("use [$HOME](/tmp/skill)", &[], &[]); assert_mentions("use $XDG_CONFIG_HOME and $beta", &["beta"], &[]); } #[test] - fn extract_skill_mentions_requires_link_syntax() { + fn extract_tool_mentions_requires_link_syntax() { assert_mentions("[beta](/tmp/beta)", &[], &[]); assert_mentions("[$beta] /tmp/beta", &["beta"], &[]); assert_mentions("[$beta]()", &["beta"], &[]); } #[test] - fn extract_skill_mentions_trims_linked_paths_and_allows_spacing() { + fn extract_tool_mentions_trims_linked_paths_and_allows_spacing() { assert_mentions("use [$beta] ( /tmp/beta )", &["beta"], &["/tmp/beta"]); } #[test] - fn extract_skill_mentions_stops_at_non_name_chars() { + fn extract_tool_mentions_stops_at_non_name_chars() { assert_mentions( "use $alpha.skill and $beta_extra", &["alpha", "beta_extra"], @@ -431,8 +564,9 @@ mod tests { text: "first $alpha-skill then $beta-skill".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); // Text scanning should not change the previous selection ordering semantics. assert_eq!(selected, vec![beta, alpha]); @@ -453,8 +587,9 @@ mod tests { path: PathBuf::from("/tmp/beta"), }, ]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); assert_eq!(selected, vec![alpha]); } @@ -467,25 +602,27 @@ mod tests { text: "use [$alpha-skill](/tmp/alpha) and [$alpha-skill](/tmp/alpha)".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); assert_eq!(selected, vec![alpha]); } #[test] - fn collect_explicit_skill_mentions_dedupes_by_name() { + fn collect_explicit_skill_mentions_skips_ambiguous_name() { let alpha = make_skill("demo-skill", "/tmp/alpha"); let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha.clone(), beta]; + let skills = vec![alpha, beta]; let inputs = vec![UserInput::Text { text: "use $demo-skill and again $demo-skill".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - assert_eq!(selected, vec![alpha]); + assert_eq!(selected, Vec::new()); } #[test] @@ -497,26 +634,58 @@ mod tests { text: "use $demo-skill and [$demo-skill](/tmp/beta)".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); assert_eq!(selected, vec![beta]); } #[test] - fn collect_explicit_skill_mentions_falls_back_when_linked_path_disabled() { + fn collect_explicit_skill_mentions_skips_plain_name_when_connector_matches() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![UserInput::Text { + text: "use $alpha-skill".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); + } + + #[test] + fn collect_explicit_skill_mentions_allows_explicit_path_with_connector_conflict() { + let alpha = make_skill("alpha-skill", "/tmp/alpha"); + let skills = vec![alpha.clone()]; + let inputs = vec![UserInput::Text { + text: "use [$alpha-skill](/tmp/alpha)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, vec![alpha]); + } + + #[test] + fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() { let alpha = make_skill("demo-skill", "/tmp/alpha"); let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha, beta.clone()]; + let skills = vec![alpha, beta]; let inputs = vec![UserInput::Text { text: "use [$demo-skill](/tmp/alpha)".to_string(), text_elements: Vec::new(), }]; let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]); + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &disabled); + let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts); - assert_eq!(selected, vec![beta]); + assert_eq!(selected, Vec::new()); } #[test] @@ -528,24 +697,41 @@ mod tests { text: "use [$demo-skill](/tmp/beta)".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); assert_eq!(selected, vec![beta]); } #[test] - fn collect_explicit_skill_mentions_falls_back_to_name_when_path_missing() { + fn collect_explicit_skill_mentions_skips_missing_path_with_no_fallback() { let alpha = make_skill("demo-skill", "/tmp/alpha"); let beta = make_skill("demo-skill", "/tmp/beta"); - let skills = vec![alpha.clone(), beta]; + let skills = vec![alpha, beta]; let inputs = vec![UserInput::Text { text: "use [$demo-skill](/tmp/missing)".to_string(), text_elements: Vec::new(), }]; + let connector_counts = HashMap::new(); - let selected = collect_explicit_skill_mentions(&inputs, &skills, &HashSet::new()); + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); - assert_eq!(selected, vec![alpha]); + assert_eq!(selected, Vec::new()); + } + + #[test] + fn collect_explicit_skill_mentions_skips_missing_path_without_fallback() { + let alpha = make_skill("demo-skill", "/tmp/alpha"); + let skills = vec![alpha]; + let inputs = vec![UserInput::Text { + text: "use [$demo-skill](/tmp/missing)".to_string(), + text_elements: Vec::new(), + }]; + let connector_counts = HashMap::new(); + + let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts); + + assert_eq!(selected, Vec::new()); } } diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 526e240a6..744cc73d9 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -42,6 +42,15 @@ impl IdTokenInfo { PlanType::Unknown(s) => s.clone(), }) } + + pub fn is_workspace_account(&self) -> bool { + matches!( + self.chatgpt_plan_type, + Some(PlanType::Known( + KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu + )) + ) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -140,6 +149,7 @@ where #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use serde::Serialize; #[test] @@ -200,4 +210,19 @@ mod tests { assert!(info.email.is_none()); assert!(info.get_chatgpt_plan_type().is_none()); } + + #[test] + fn workspace_account_detection_matches_workspace_plans() { + let workspace = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Business)), + ..IdTokenInfo::default() + }; + assert_eq!(workspace.is_workspace_account(), true); + + let personal = IdTokenInfo { + chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + ..IdTokenInfo::default() + }; + assert_eq!(personal.is_workspace_account(), false); + } } diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index ee49cac0a..8cdc5c957 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -82,6 +82,15 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg::TurnComplete` โ€“ Contains a `response_id` bookmark for last `response_id` executed by the turn. This can be used to continue the turn at a later point in time, perhaps with additional user input. - `EventMsg::ListSkillsResponse` โ€“ Response payload with per-cwd skill entries (`cwd`, `skills`, `errors`) +### UserInput items + +`Op::UserTurn` content items can include: + +- `text` โ€“ Plain text plus optional UI text elements. +- `image` / `local_image` โ€“ Image inputs. +- `skill` โ€“ Explicit skill selection (`name`, `path` to `SKILL.md`). +- `mention` โ€“ Explicit app/connector selection (`name`, `path` in `app://{connector_id}` form). + Note: For v1 wire compatibility, `EventMsg::TurnStarted` and `EventMsg::TurnComplete` serialize as `task_started` / `task_complete`. The deserializer accepts both `task_*` and `turn_*` tags. The `response_id` returned from each turn matches the OpenAI `response_id` stored in the API's `/responses` endpoint. It can be stored and used in future `Sessions` to resume threads of work. diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 50e8ee342..759ff5aae 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -674,7 +674,7 @@ impl From> for ResponseInputItem { image_index += 1; local_image_content_items_with_label_number(&path, Some(image_index)) } - UserInput::Skill { .. } => Vec::new(), // Skill bodies are injected later in core + UserInput::Skill { .. } | UserInput::Mention { .. } => Vec::new(), // Tool bodies are injected later in core }) .collect::>(), } diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index e9ca09580..d40511f34 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -29,6 +29,8 @@ pub enum UserInput { name: String, path: std::path::PathBuf, }, + /// Explicit mention selected by the user (name + app://connector id). + Mention { name: String, path: String }, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1d2054236..820fe20e9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -30,6 +30,7 @@ codex-ansi-escape = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-backend-client = { workspace = true } +codex-chatgpt = { workspace = true } codex-common = { workspace = true, features = [ "cli", "elapsed", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e5db8fc6b..ae1dc4b32 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1508,6 +1508,21 @@ impl App { )); tui.frame_requester().schedule_frame(); } + AppEvent::OpenAppLink { + title, + description, + instructions, + url, + is_installed, + } => { + self.chat_widget.open_app_link_view( + title, + description, + instructions, + url, + is_installed, + ); + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -1517,6 +1532,9 @@ impl App { AppEvent::RateLimitSnapshotFetched(snapshot) => { self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); } + AppEvent::ConnectorsLoaded(result) => { + self.chat_widget.on_connectors_loaded(result); + } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b65d1efb6..bbd228e11 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; +use codex_chatgpt::connectors::AppInfo; use codex_common::approval_presets::ApprovalPreset; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; @@ -40,6 +41,11 @@ pub(crate) enum WindowsSandboxFallbackReason { ElevationFailed, } +#[derive(Debug, Clone)] +pub(crate) struct ConnectorsSnapshot { + pub(crate) connectors: Vec, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { @@ -89,9 +95,21 @@ pub(crate) enum AppEvent { /// Result of refreshing rate limits RateLimitSnapshotFetched(RateLimitSnapshot), + /// Result of prefetching connectors. + ConnectorsLoaded(Result), + /// Result of computing a `/diff` command. DiffResult(String), + /// Open the app link view in the bottom pane. + OpenAppLink { + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + }, + InsertHistoryCell(Box), StartCommitAnimation, diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs new file mode 100644 index 000000000..1f672607a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -0,0 +1,163 @@ +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use textwrap::wrap; + +use super::CancellationEvent; +use super::bottom_pane_view::BottomPaneView; +use crate::key_hint; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::style::user_message_style; +use crate::wrapping::word_wrap_lines; + +pub(crate) struct AppLinkView { + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + complete: bool, +} + +impl AppLinkView { + pub(crate) fn new( + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + ) -> Self { + Self { + title, + description, + instructions, + url, + is_installed, + complete: false, + } + } + + fn content_lines(&self, width: u16) -> Vec> { + let usable_width = width.max(1) as usize; + let mut lines: Vec> = Vec::new(); + + lines.push(Line::from(self.title.clone().bold())); + if let Some(description) = self + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + { + for line in wrap(description, usable_width) { + lines.push(Line::from(line.into_owned().dim())); + } + } + lines.push(Line::from("")); + if self.is_installed { + for line in wrap("Use $ to insert this app into the prompt.", usable_width) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + } + + let instructions = self.instructions.trim(); + if !instructions.is_empty() { + for line in wrap(instructions, usable_width) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Newly installed apps can take a few minutes to appear in /apps.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + if !self.is_installed { + for line in wrap( + "After installed, use $ to insert this app into the prompt.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } + lines.push(Line::from("")); + } + + lines.push(Line::from(vec!["Open:".dim()])); + let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); + lines.extend(word_wrap_lines(vec![url_line], usable_width)); + + lines + } +} + +impl BottomPaneView for AppLinkView { + fn handle_key_event(&mut self, key_event: KeyEvent) { + if let KeyEvent { + code: KeyCode::Esc, .. + } = key_event + { + self.on_ctrl_c(); + } + } + + fn on_ctrl_c(&mut self) -> CancellationEvent { + self.complete = true; + CancellationEvent::Handled + } + + fn is_complete(&self) -> bool { + self.complete + } +} + +impl crate::render::renderable::Renderable for AppLinkView { + fn desired_height(&self, width: u16) -> u16 { + let content_width = width.saturating_sub(4).max(1); + let content_lines = self.content_lines(content_width); + content_lines.len() as u16 + 3 + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + + Block::default() + .style(user_message_style()) + .render(area, buf); + + let [content_area, hint_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area); + let inner = content_area.inset(Insets::vh(1, 2)); + let content_width = inner.width.max(1); + let lines = self.content_lines(content_width); + Paragraph::new(lines).render(inner, buf); + + if hint_area.height > 0 { + let hint_area = Rect { + x: hint_area.x.saturating_add(2), + y: hint_area.y, + width: hint_area.width.saturating_sub(2), + height: hint_area.height, + }; + hint_line().dim().render(hint_area, buf); + } + } +} + +fn hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close".into(), + ]) +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3a3f5d407..9d0cbf22e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3,7 +3,7 @@ //! It is responsible for: //! //! - Editing the input buffer (a [`TextArea`]), including placeholder "elements" for attachments. -//! - Routing keys to the active popup (slash commands, file search, skill mentions). +//! - Routing keys to the active popup (slash commands, file search, skill/apps mentions). //! - Handling submit vs newline on Enter. //! - Turning raw key streams into explicit paste operations on platforms where terminals //! don't provide reliable bracketed paste (notably Windows). @@ -124,6 +124,7 @@ use super::footer::single_line_footer_layout; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; +use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; use super::slash_commands; use crate::bottom_pane::paste_burst::FlushResult; @@ -146,6 +147,7 @@ use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::LocalImageAttachment; use crate::bottom_pane::textarea::TextArea; @@ -154,6 +156,8 @@ use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; use crate::ui_consts::LIVE_PREFIX_COLS; +use codex_chatgpt::connectors; +use codex_chatgpt::connectors::AppInfo; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -276,12 +280,15 @@ pub(crate) struct ChatComposer { context_window_percent: Option, context_window_used_tokens: Option, skills: Option>, - dismissed_skill_popup_token: Option, + connectors_snapshot: Option, + dismissed_mention_popup_token: Option, + mention_paths: HashMap, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, collaboration_modes_enabled: bool, config: ChatComposerConfig, collaboration_mode_indicator: Option, + connectors_enabled: bool, personality_command_enabled: bool, windows_degraded_sandbox_active: bool, } @@ -363,11 +370,14 @@ impl ChatComposer { context_window_percent: None, context_window_used_tokens: None, skills: None, - dismissed_skill_popup_token: None, + connectors_snapshot: None, + dismissed_mention_popup_token: None, + mention_paths: HashMap::new(), steer_enabled: false, collaboration_modes_enabled: false, config, collaboration_mode_indicator: None, + connectors_enabled: false, personality_command_enabled: false, windows_degraded_sandbox_active: false, }; @@ -380,6 +390,14 @@ impl ChatComposer { self.skills = skills; } + pub fn set_connector_mentions(&mut self, connectors_snapshot: Option) { + self.connectors_snapshot = connectors_snapshot; + } + + pub(crate) fn take_mention_paths(&mut self) -> HashMap { + std::mem::take(&mut self.mention_paths) + } + /// Enables or disables "Steer" behavior for submission keys. /// /// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and @@ -394,6 +412,10 @@ impl ChatComposer { self.collaboration_modes_enabled = enabled; } + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.connectors_enabled = enabled; + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, @@ -697,6 +719,7 @@ impl ChatComposer { self.textarea.set_text_clearing_elements(""); self.pending_pastes.clear(); self.attached_images.clear(); + self.mention_paths.clear(); self.textarea.set_text_with_elements(&text, &text_elements); @@ -1349,7 +1372,10 @@ impl ChatComposer { unreachable!(); }; - match key_event { + let mut selected_mention: Option<(String, Option)> = None; + let mut close_popup = false; + + let result = match key_event { KeyEvent { code: KeyCode::Up, .. } @@ -1376,8 +1402,8 @@ impl ChatComposer { KeyEvent { code: KeyCode::Esc, .. } => { - if let Some(tok) = self.current_skill_token() { - self.dismissed_skill_popup_token = Some(tok); + if let Some(tok) = self.current_mention_token() { + self.dismissed_mention_popup_token = Some(tok); } self.active_popup = ActivePopup::None; (InputResult::None, true) @@ -1390,15 +1416,26 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, .. } => { - let selected = popup.selected_skill().map(|skill| skill.name.clone()); - if let Some(name) = selected { - self.insert_selected_skill(&name); + if let Some(mention) = popup.selected_mention() { + selected_mention = Some((mention.insert_text.clone(), mention.path.clone())); } - self.active_popup = ActivePopup::None; + close_popup = true; (InputResult::None, true) } input => self.handle_input_basic(input), + }; + + if close_popup { + if let Some((insert_text, path)) = selected_mention { + if let Some(path) = path.as_deref() { + self.record_mention_path(&insert_text, path); + } + self.insert_selected_mention(&insert_text); + } + self.active_popup = ActivePopup::None; } + + result } fn is_image_path(path: &str) -> bool { @@ -1511,14 +1548,23 @@ impl ChatComposer { (rebuilt, rebuilt_elements) } - fn skills_enabled(&self) -> bool { - self.skills.as_ref().is_some_and(|s| !s.is_empty()) - } - pub fn skills(&self) -> Option<&Vec> { self.skills.as_ref() } + fn mentions_enabled(&self) -> bool { + let skills_ready = self + .skills + .as_ref() + .is_some_and(|skills| !skills.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 + } + /// Extract a token prefixed with `prefix` under the cursor, if any. /// /// The returned string **does not** include the prefix. @@ -1632,8 +1678,8 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', false) } - fn current_skill_token(&self) -> Option { - if !self.skills_enabled() { + fn current_mention_token(&self) -> Option { + if !self.mentions_enabled() { return None; } Self::current_prefixed_token(&self.textarea, '$', true) @@ -1691,7 +1737,7 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } - fn insert_selected_skill(&mut self, skill_name: &str) { + fn insert_selected_mention(&mut self, insert_text: &str) { let cursor_offset = self.textarea.cursor(); let text = self.textarea.text(); let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); @@ -1712,7 +1758,7 @@ impl ChatComposer { .unwrap_or(after_cursor.len()); let end_idx = safe_cursor + end_rel_idx; - let inserted = format!("${skill_name}"); + let inserted = insert_text.to_string(); let mut new_text = String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); @@ -1721,12 +1767,35 @@ impl ChatComposer { new_text.push(' '); new_text.push_str(&text[end_idx..]); - // Skill insertion rebuilds plain text, so drop existing elements. + // Mention insertion rebuilds plain text, so drop existing elements. self.textarea.set_text_clearing_elements(&new_text); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.textarea.set_cursor(new_cursor); } + fn record_mention_path(&mut self, insert_text: &str, path: &str) { + let Some(name) = Self::mention_name_from_insert_text(insert_text) else { + return; + }; + self.mention_paths.insert(name, path.to_string()); + } + + fn mention_name_from_insert_text(insert_text: &str) -> Option { + let name = insert_text.strip_prefix('$')?; + if name.is_empty() { + return None; + } + if name + .as_bytes() + .iter() + .all(|byte| is_mention_name_char(*byte)) + { + Some(name.to_string()) + } else { + None + } + } + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. /// On success, clears pending paste payloads because placeholders have been expanded. fn prepare_submission_text(&mut self) -> Option<(String, Vec)> { @@ -1765,6 +1834,7 @@ impl ChatComposer { let is_builtin = slash_commands::find_builtin_command( name, self.collaboration_modes_enabled, + self.connectors_enabled, self.personality_command_enabled, self.windows_degraded_sandbox_active, ) @@ -1951,6 +2021,7 @@ impl ChatComposer { && let Some(cmd) = slash_commands::find_builtin_command( name, self.collaboration_modes_enabled, + self.connectors_enabled, self.personality_command_enabled, self.windows_degraded_sandbox_active, ) @@ -1979,6 +2050,7 @@ impl ChatComposer { && let Some(cmd) = slash_commands::find_builtin_command( name, self.collaboration_modes_enabled, + self.connectors_enabled, self.personality_command_enabled, self.windows_degraded_sandbox_active, ) @@ -2388,10 +2460,10 @@ impl ChatComposer { self.active_popup = ActivePopup::None; return; } - let skill_token = self.current_skill_token(); + let mention_token = self.current_mention_token(); let allow_command_popup = - self.slash_commands_enabled() && file_token.is_none() && skill_token.is_none(); + self.slash_commands_enabled() && file_token.is_none() && mention_token.is_none(); self.sync_command_popup(allow_command_popup); if matches!(self.active_popup, ActivePopup::Command(_)) { @@ -2401,20 +2473,20 @@ impl ChatComposer { self.current_file_query = None; } self.dismissed_file_popup_token = None; - self.dismissed_skill_popup_token = None; + self.dismissed_mention_popup_token = None; return; } - if let Some(token) = skill_token { + if let Some(token) = mention_token { if self.current_file_query.is_some() { self.app_event_tx .send(AppEvent::StartFileSearch(String::new())); self.current_file_query = None; } - self.sync_skill_popup(token); + self.sync_mention_popup(token); return; } - self.dismissed_skill_popup_token = None; + self.dismissed_mention_popup_token = None; if let Some(token) = file_token { self.sync_file_search_popup(token); @@ -2477,6 +2549,7 @@ impl ChatComposer { if slash_commands::has_builtin_prefix( name, self.collaboration_modes_enabled, + self.connectors_enabled, self.personality_command_enabled, self.windows_degraded_sandbox_active, ) { @@ -2529,11 +2602,13 @@ impl ChatComposer { _ => { if is_editing_slash_command_name { let collaboration_modes_enabled = self.collaboration_modes_enabled; + let connectors_enabled = self.connectors_enabled; let personality_command_enabled = self.personality_command_enabled; let mut command_popup = CommandPopup::new( self.custom_prompts.clone(), CommandPopupFlags { collaboration_modes_enabled, + connectors_enabled, personality_command_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, }, @@ -2594,32 +2669,99 @@ impl ChatComposer { self.dismissed_file_popup_token = None; } - fn sync_skill_popup(&mut self, query: String) { - if self.dismissed_skill_popup_token.as_ref() == Some(&query) { + fn sync_mention_popup(&mut self, query: String) { + if self.dismissed_mention_popup_token.as_ref() == Some(&query) { return; } - let skills = match self.skills.as_ref() { - Some(skills) if !skills.is_empty() => skills.clone(), - _ => { - self.active_popup = ActivePopup::None; - return; - } - }; + let mentions = self.mention_items(); + if mentions.is_empty() { + self.active_popup = ActivePopup::None; + return; + } match &mut self.active_popup { ActivePopup::Skill(popup) => { popup.set_query(&query); - popup.set_skills(skills); + popup.set_mentions(mentions); } _ => { - let mut popup = SkillPopup::new(skills); + let mut popup = SkillPopup::new(mentions); popup.set_query(&query); self.active_popup = ActivePopup::Skill(popup); } } } + fn mention_items(&self) -> Vec { + let mut mentions = Vec::new(); + + if let Some(skills) = self.skills.as_ref() { + for skill in skills { + let display_name = skill_display_name(skill).to_string(); + let description = skill_description(skill); + let skill_name = skill.name.clone(); + let search_terms = if display_name == skill.name { + vec![skill_name.clone()] + } else { + vec![skill_name.clone(), display_name.clone()] + }; + mentions.push(MentionItem { + display_name, + description, + insert_text: format!("${skill_name}"), + search_terms, + path: Some(skill.path.to_string_lossy().into_owned()), + }); + } + } + + if self.connectors_enabled + && let Some(snapshot) = self.connectors_snapshot.as_ref() + { + for connector in &snapshot.connectors { + if !connector.is_accessible { + 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(); + mentions.push(MentionItem { + display_name: display_name.clone(), + description, + insert_text: format!("${slug}"), + search_terms, + path: Some(format!("app://{connector_id}")), + }); + } + } + + mentions + } + + fn connector_brief_description(connector: &AppInfo) -> String { + let status_label = if connector.is_accessible { + "Connected" + } else { + "Can be installed" + }; + match Self::connector_description(connector) { + Some(description) => format!("{status_label} - {description}"), + None => status_label.to_string(), + } + } + + fn connector_description(connector: &AppInfo) -> Option { + connector + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) + } + fn set_has_focus(&mut self, has_focus: bool) { self.has_focus = has_focus; } @@ -2658,6 +2800,29 @@ impl ChatComposer { } } +fn skill_display_name(skill: &SkillMetadata) -> &str { + skill + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .unwrap_or(&skill.name) +} + +fn skill_description(skill: &SkillMetadata) -> Option { + let description = skill + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()) + .or(skill.short_description.as_deref()) + .unwrap_or(&skill.description); + let trimmed = description.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn is_mention_name_char(byte: u8) -> bool { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') +} + impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if !self.input_enabled { @@ -2938,6 +3103,7 @@ mod tests { use tempfile::tempdir; use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; use crate::bottom_pane::ChatComposer; use crate::bottom_pane::InputResult; diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index c1cdd5374..5423231db 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -32,6 +32,7 @@ pub(crate) struct CommandPopup { #[derive(Clone, Copy, Debug, Default)] pub(crate) struct CommandPopupFlags { pub(crate) collaboration_modes_enabled: bool, + pub(crate) connectors_enabled: bool, pub(crate) personality_command_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, } @@ -41,6 +42,7 @@ impl CommandPopup { // Keep built-in availability in sync with the composer. let builtins = slash_commands::builtins_for_input( flags.collaboration_modes_enabled, + flags.connectors_enabled, flags.personality_command_enabled, flags.windows_degraded_sandbox_active, ); @@ -478,6 +480,7 @@ mod tests { Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, + connectors_enabled: false, personality_command_enabled: true, windows_degraded_sandbox_active: false, }, @@ -496,6 +499,7 @@ mod tests { Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, + connectors_enabled: false, personality_command_enabled: false, windows_degraded_sandbox_active: false, }, @@ -522,6 +526,7 @@ mod tests { Vec::new(), CommandPopupFlags { collaboration_modes_enabled: true, + connectors_enabled: false, personality_command_enabled: true, windows_degraded_sandbox_active: false, }, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 701e0762c..d08ccf506 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -13,8 +13,10 @@ //! //! Some UI is time-based rather than input-based, such as the transient "press again to quit" //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. +use std::collections::HashMap; use std::path::PathBuf; +use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::queued_user_messages::QueuedUserMessages; use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter; @@ -36,8 +38,10 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use std::time::Duration; +mod app_link_view; mod approval_overlay; mod request_user_input; +pub(crate) use app_link_view::AppLinkView; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; pub(crate) use request_user_input::RequestUserInputOverlay; @@ -204,6 +208,15 @@ impl BottomPane { self.request_redraw(); } + pub fn set_connectors_snapshot(&mut self, snapshot: Option) { + self.composer.set_connector_mentions(snapshot); + self.request_redraw(); + } + + pub fn take_mention_paths(&mut self) -> HashMap { + self.composer.take_mention_paths() + } + pub fn set_steer_enabled(&mut self, enabled: bool) { self.composer.set_steer_enabled(enabled); } @@ -213,6 +226,10 @@ impl BottomPane { self.request_redraw(); } + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.composer.set_connectors_enabled(enabled); + } + #[cfg(target_os = "windows")] pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { self.composer.set_windows_degraded_sandbox_active(enabled); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 473695320..390b44295 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -14,30 +14,35 @@ use super::selection_popup_common::render_rows_single_line; use crate::key_hint; use crate::render::Insets; use crate::render::RectExt; -use codex_core::skills::model::SkillMetadata; +use crate::text_formatting::truncate_text; +use codex_common::fuzzy_match::fuzzy_match; -use crate::skills_helpers::match_skill; -use crate::skills_helpers::skill_description; -use crate::skills_helpers::skill_display_name; -use crate::skills_helpers::truncated_skill_display_name; +#[derive(Clone, Debug)] +pub(crate) struct MentionItem { + pub(crate) display_name: String, + pub(crate) description: Option, + pub(crate) insert_text: String, + pub(crate) search_terms: Vec, + pub(crate) path: Option, +} pub(crate) struct SkillPopup { query: String, - skills: Vec, + mentions: Vec, state: ScrollState, } impl SkillPopup { - pub(crate) fn new(skills: Vec) -> Self { + pub(crate) fn new(mentions: Vec) -> Self { Self { query: String::new(), - skills, + mentions, state: ScrollState::new(), } } - pub(crate) fn set_skills(&mut self, skills: Vec) { - self.skills = skills; + pub(crate) fn set_mentions(&mut self, mentions: Vec) { + self.mentions = mentions; self.clamp_selection(); } @@ -64,11 +69,11 @@ impl SkillPopup { self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); } - pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { + pub(crate) fn selected_mention(&self) -> Option<&MentionItem> { let matches = self.filtered_items(); let idx = self.state.selected_idx?; - let skill_idx = matches.get(idx)?; - self.skills.get(*skill_idx) + let mention_idx = matches.get(idx)?; + self.mentions.get(*mention_idx) } fn clamp_selection(&mut self) { @@ -88,14 +93,14 @@ impl SkillPopup { matches .into_iter() .map(|(idx, indices, _score)| { - let skill = &self.skills[idx]; - let name = truncated_skill_display_name(skill); - let description = skill_description(skill).to_string(); + let mention = &self.mentions[idx]; + let name = truncate_text(&mention.display_name, 21); + let description = mention.description.clone().unwrap_or_default(); GenericDisplayRow { name, match_indices: indices, display_shortcut: None, - description: Some(description), + description: Some(description).filter(|desc| !desc.is_empty()), is_disabled: false, disabled_reason: None, wrap_indent: None, @@ -109,23 +114,48 @@ impl SkillPopup { let mut out: Vec<(usize, Option>, i32)> = Vec::new(); if filter.is_empty() { - for (idx, _skill) in self.skills.iter().enumerate() { + for (idx, _mention) in self.mentions.iter().enumerate() { out.push((idx, None, 0)); } return out; } - for (idx, skill) in self.skills.iter().enumerate() { - let display_name = skill_display_name(skill); - if let Some((indices, score)) = match_skill(filter, display_name, &skill.name) { + for (idx, mention) in self.mentions.iter().enumerate() { + let mut best_match: Option<(Option>, i32)> = None; + + if let Some((indices, score)) = fuzzy_match(&mention.display_name, filter) { + best_match = Some((Some(indices), score)); + } + + for term in &mention.search_terms { + if term == &mention.display_name { + continue; + } + + if let Some((_indices, score)) = fuzzy_match(term, filter) { + match best_match.as_mut() { + Some((best_indices, best_score)) => { + if score > *best_score { + *best_score = score; + *best_indices = None; + } + } + None => { + best_match = Some((None, score)); + } + } + } + } + + if let Some((indices, score)) = best_match { out.push((idx, indices, score)); } } out.sort_by(|a, b| { a.2.cmp(&b.2).then_with(|| { - let an = skill_display_name(&self.skills[a.0]); - let bn = skill_display_name(&self.skills[b.0]); + let an = self.mentions[a.0].display_name.as_str(); + let bn = self.mentions[b.0].display_name.as_str(); an.cmp(bn) }) }); @@ -154,7 +184,7 @@ impl WidgetRef for SkillPopup { &rows, &self.state, MAX_POPUP_ROWS, - "no skills", + "no matches", ); if let Some(hint_area) = hint_area { let hint_area = Rect { @@ -172,7 +202,7 @@ fn skill_popup_hint_line() -> Line<'static> { Line::from(vec![ "Press ".into(), key_hint::plain(KeyCode::Enter).into(), - " to select or ".into(), + " to insert or ".into(), key_hint::plain(KeyCode::Esc).into(), " to close".into(), ]) diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index e0707a422..1efab04ec 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -11,6 +11,7 @@ use crate::slash_command::built_in_slash_commands; /// Return the built-ins that should be visible/usable for the current input. pub(crate) fn builtins_for_input( collaboration_modes_enabled: bool, + connectors_enabled: bool, personality_command_enabled: bool, allow_elevate_sandbox: bool, ) -> Vec<(&'static str, SlashCommand)> { @@ -18,6 +19,7 @@ pub(crate) fn builtins_for_input( .into_iter() .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .filter(|(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps) .filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality) .collect() } @@ -26,11 +28,13 @@ pub(crate) fn builtins_for_input( pub(crate) fn find_builtin_command( name: &str, collaboration_modes_enabled: bool, + connectors_enabled: bool, personality_command_enabled: bool, allow_elevate_sandbox: bool, ) -> Option { builtins_for_input( collaboration_modes_enabled, + connectors_enabled, personality_command_enabled, allow_elevate_sandbox, ) @@ -43,11 +47,13 @@ pub(crate) fn find_builtin_command( pub(crate) fn has_builtin_prefix( name: &str, collaboration_modes_enabled: bool, + connectors_enabled: bool, personality_command_enabled: bool, allow_elevate_sandbox: bool, ) -> bool { builtins_for_input( collaboration_modes_enabled, + connectors_enabled, personality_command_enabled, allow_elevate_sandbox, ) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index dcbb4d7c2..8a0dd81af 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -31,6 +31,7 @@ use std::time::Instant; use crate::version::CODEX_CLI_VERSION; use codex_app_server_protocol::AuthMode; use codex_backend_client::Client as BackendClient; +use codex_chatgpt::connectors; use codex_core::config::Config; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; @@ -131,6 +132,7 @@ const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; @@ -189,7 +191,9 @@ pub(crate) use self::agent::spawn_op_forwarder; mod session_header; use self::session_header::SessionHeader; mod skills; -use self::skills::find_skill_mentions; +use self::skills::collect_tool_mentions; +use self::skills::find_app_mentions; +use self::skills::find_skill_mentions_with_tool_mentions; use crate::streaming::controller::StreamController; use std::path::Path; @@ -386,6 +390,15 @@ enum RateLimitSwitchPromptState { Shown, } +#[derive(Debug, Clone, Default)] +enum ConnectorsCacheState { + #[default] + Uninitialized, + Loading, + Ready(ConnectorsSnapshot), + Failed(String), +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -460,6 +473,7 @@ pub(crate) struct ChatWidget { /// bottom pane is treated as "running" while this is populated, even if no agent turn is /// currently executing. mcp_startup_status: Option>, + connectors_cache: ConnectorsCacheState, // Queue of interruptive UI events deferred during an active write cycle interrupts: InterruptManager, // Accumulates the current reasoning block text to extract a header @@ -549,6 +563,7 @@ pub(crate) struct UserMessage { text: String, local_images: Vec, text_elements: Vec, + mention_paths: HashMap, } impl From for UserMessage { @@ -558,6 +573,7 @@ impl From for UserMessage { local_images: Vec::new(), // Plain text conversion has no UI element ranges. text_elements: Vec::new(), + mention_paths: HashMap::new(), } } } @@ -569,6 +585,7 @@ impl From<&str> for UserMessage { local_images: Vec::new(), // Plain text conversion has no UI element ranges. text_elements: Vec::new(), + mention_paths: HashMap::new(), } } } @@ -594,6 +611,7 @@ pub(crate) fn create_initial_user_message( text, local_images, text_elements, + mention_paths: HashMap::new(), }) } } @@ -607,12 +625,14 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) text, text_elements, local_images, + mention_paths, } = message; if local_images.is_empty() { return UserMessage { text, text_elements, local_images, + mention_paths, }; } @@ -667,6 +687,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) text: rebuilt, local_images: remapped_images, text_elements: rebuilt_elements, + mention_paths, } } @@ -732,6 +753,7 @@ impl ChatWidget { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.set_skills(None); + self.bottom_pane.set_connectors_snapshot(None); self.thread_id = Some(event.session_id); self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); @@ -762,6 +784,9 @@ impl ChatWidget { cwds: Vec::new(), force_reload: true, }); + if self.connectors_enabled() { + self.prefetch_connectors(); + } if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -797,6 +822,25 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn open_app_link_view( + &mut self, + title: String, + description: Option, + instructions: String, + url: String, + is_installed: bool, + ) { + let view = crate::bottom_pane::AppLinkView::new( + title, + description, + instructions, + url, + is_installed, + ); + self.bottom_pane.show_view(Box::new(view)); + self.request_redraw(); + } + pub(crate) fn open_feedback_consent(&mut self, category: crate::app_event::FeedbackCategory) { let params = crate::bottom_pane::feedback_upload_consent_params( self.app_event_tx.clone(), @@ -1240,6 +1284,7 @@ impl ChatWidget { text: self.bottom_pane.composer_text(), text_elements: self.bottom_pane.composer_text_elements(), local_images: self.bottom_pane.composer_local_images(), + mention_paths: HashMap::new(), }; let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); @@ -1251,6 +1296,7 @@ impl ChatWidget { text: String::new(), text_elements: Vec::new(), local_images: Vec::new(), + mention_paths: HashMap::new(), }; let mut combined_offset = 0usize; let mut next_image_label = 1usize; @@ -1272,6 +1318,7 @@ impl ChatWidget { elem })); combined.local_images.extend(message.local_images); + combined.mention_paths.extend(message.mention_paths); } Some(combined) @@ -2028,6 +2075,7 @@ impl ChatWidget { unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2071,6 +2119,10 @@ impl ChatWidget { ); widget.update_collaboration_mode_indicator(); + widget + .bottom_pane + .set_connectors_enabled(widget.config.features.enabled(Feature::Apps)); + widget } @@ -2161,6 +2213,7 @@ impl ChatWidget { unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2287,6 +2340,7 @@ impl ChatWidget { unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -2436,6 +2490,7 @@ impl ChatWidget { .bottom_pane .take_recent_submission_images_with_placeholders(), text_elements, + mention_paths: self.bottom_pane.take_mention_paths(), }; if self.is_session_configured() { // Submitted is only emitted when steer is enabled (Enter sends immediately). @@ -2458,6 +2513,7 @@ impl ChatWidget { .bottom_pane .take_recent_submission_images_with_placeholders(), text_elements, + mention_paths: self.bottom_pane.take_mention_paths(), }; self.queue_user_message(user_message); } @@ -2675,6 +2731,9 @@ impl ChatWidget { SlashCommand::Mcp => { self.add_mcp_output(); } + SlashCommand::Apps => { + self.add_connectors_output(); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -2833,6 +2892,7 @@ impl ChatWidget { text, local_images, text_elements, + mention_paths, } = user_message; if text.is_empty() && local_images.is_empty() { return; @@ -2871,8 +2931,15 @@ impl ChatWidget { }); } + let mentions = collect_tool_mentions(&text, &mention_paths); + let mut skill_names_lower: HashSet = HashSet::new(); + if let Some(skills) = self.bottom_pane.skills() { - let skill_mentions = find_skill_mentions(&text, skills); + skill_names_lower = skills + .iter() + .map(|skill| skill.name.to_ascii_lowercase()) + .collect(); + let skill_mentions = find_skill_mentions_with_tool_mentions(&mentions, skills); for skill in skill_mentions { items.push(UserInput::Skill { name: skill.name.clone(), @@ -2881,6 +2948,17 @@ impl ChatWidget { } } + if let Some(apps) = self.connectors_for_mentions() { + let app_mentions = find_app_mentions(&mentions, apps, &skill_names_lower); + for app in app_mentions { + let app_id = app.id.as_str(); + items.push(UserInput::Mention { + name: app.name.clone(), + path: format!("app://{app_id}"), + }); + } + } + let effective_mode = self.effective_collaboration_mode(); let collaboration_mode = if self.collaboration_modes_enabled() { self.active_collaboration_mask @@ -3289,6 +3367,28 @@ impl ChatWidget { } } + fn prefetch_connectors(&mut self) { + if !self.connectors_enabled() { + return; + } + if matches!(self.connectors_cache, ConnectorsCacheState::Loading) { + return; + } + + self.connectors_cache = ConnectorsCacheState::Loading; + let config = self.config.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result: Result = async { + let connectors = connectors::list_connectors(&config).await?; + Ok(ConnectorsSnapshot { connectors }) + } + .await; + let result = result.map_err(|err| format!("Failed to load apps: {err}")); + app_event_tx.send(AppEvent::ConnectorsLoaded(result)); + }); + } + fn prefetch_rate_limits(&mut self) { self.stop_rate_limit_poller(); @@ -4973,6 +5073,21 @@ impl ChatWidget { self.request_redraw(); } + fn connectors_enabled(&self) -> bool { + self.config.features.enabled(Feature::Apps) + } + + fn connectors_for_mentions(&self) -> Option<&[connectors::AppInfo]> { + if !self.connectors_enabled() { + return None; + } + + match &self.connectors_cache { + ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), + _ => None, + } + } + /// 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); @@ -5036,6 +5151,152 @@ impl ChatWidget { } } + pub(crate) fn add_connectors_output(&mut self) { + if !self.connectors_enabled() { + self.add_info_message( + "Apps are disabled.".to_string(), + Some("Enable the apps feature to use $ or /apps.".to_string()), + ); + return; + } + + match self.connectors_cache.clone() { + ConnectorsCacheState::Ready(snapshot) => { + if snapshot.connectors.is_empty() { + self.add_info_message("No apps available.".to_string(), None); + } else { + self.open_connectors_popup(&snapshot.connectors); + } + } + ConnectorsCacheState::Failed(err) => { + self.add_to_history(history_cell::new_error_event(err)); + // Retry on demand so `/apps` can recover after transient failures. + self.prefetch_connectors(); + } + ConnectorsCacheState::Loading => { + self.add_to_history(history_cell::new_info_event( + "Apps are still loading.".to_string(), + Some("Try again in a moment.".to_string()), + )); + } + ConnectorsCacheState::Uninitialized => { + self.prefetch_connectors(); + self.add_to_history(history_cell::new_info_event( + "Apps are still loading.".to_string(), + Some("Try again in a moment.".to_string()), + )); + } + } + self.request_redraw(); + } + + fn open_connectors_popup(&mut self, connectors: &[connectors::AppInfo]) { + let total = connectors.len(); + let installed = connectors + .iter() + .filter(|connector| connector.is_accessible) + .count(); + let mut header = ColumnRenderable::new(); + header.push(Line::from("Apps".bold())); + header.push(Line::from( + "Use $ to insert an installed app into your prompt.".dim(), + )); + header.push(Line::from( + format!("Installed {installed} of {total} available apps.").dim(), + )); + let mut items: Vec = Vec::with_capacity(connectors.len()); + for connector in connectors { + let connector_label = connectors::connector_display_label(connector); + let connector_title = connector_label.clone(); + let link_description = Self::connector_description(connector); + let description = Self::connector_brief_description(connector); + let search_value = format!("{connector_label} {}", connector.id); + let mut item = SelectionItem { + name: connector_label, + description: Some(description), + search_value: Some(search_value), + ..Default::default() + }; + let is_installed = connector.is_accessible; + let (selected_label, missing_label, instructions) = if connector.is_accessible { + ( + "Press Enter to view the app link.", + "App link unavailable.", + "Manage this app in your browser.", + ) + } else { + ( + "Press Enter to view the install link.", + "Install link unavailable.", + "Install this app in your browser, then reload Codex.", + ) + }; + if let Some(install_url) = connector.install_url.clone() { + let title = connector_title.clone(); + let instructions = instructions.to_string(); + let description = link_description.clone(); + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAppLink { + title: title.clone(), + description: description.clone(), + instructions: instructions.clone(), + url: install_url.clone(), + is_installed, + }); + })]; + item.dismiss_on_select = true; + item.selected_description = Some(selected_label.to_string()); + } else { + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(missing_label.to_string(), None), + ))); + })]; + item.dismiss_on_select = true; + item.selected_description = Some(missing_label.to_string()); + } + items.push(item); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(Self::connectors_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search apps".to_string()), + ..Default::default() + }); + } + + fn connectors_popup_hint_line() -> Line<'static> { + Line::from(vec![ + "Press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close.".into(), + ]) + } + + fn connector_brief_description(connector: &connectors::AppInfo) -> String { + let status_label = if connector.is_accessible { + "Connected" + } else { + "Can be installed" + }; + match Self::connector_description(connector) { + Some(description) => format!("{status_label} ยท {description}"), + None => status_label.to_string(), + } + } + + fn connector_description(connector: &connectors::AppInfo) -> Option { + connector + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) + } + /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); @@ -5220,6 +5481,19 @@ impl ChatWidget { self.set_skills_from_response(&ev); } + pub(crate) fn on_connectors_loaded(&mut self, result: Result) { + self.connectors_cache = match result { + Ok(connectors) => ConnectorsCacheState::Ready(connectors), + Err(err) => ConnectorsCacheState::Failed(err), + }; + if let ConnectorsCacheState::Ready(snapshot) = &self.connectors_cache { + self.bottom_pane + .set_connectors_snapshot(Some(snapshot.clone())); + } else { + self.bottom_pane.set_connectors_snapshot(None); + } + } + 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 d6893a9e3..0920c8837 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -12,6 +12,8 @@ use crate::bottom_pane::SkillsToggleView; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::skills_helpers::skill_description; use crate::skills_helpers::skill_display_name; +use codex_chatgpt::connectors::AppInfo; +use codex_core::connectors::connector_mention_slug; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::SkillMetadata as ProtocolSkillMetadata; use codex_core::protocol::SkillsListEntry; @@ -192,22 +194,256 @@ fn protocol_skill_to_core(skill: &ProtocolSkillMetadata) -> SkillMetadata { } } -pub(crate) fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut matches: Vec = Vec::new(); - for skill in skills { - if seen.contains(&skill.name) { - continue; - } - let needle = format!("${}", skill.name); - if text.contains(&needle) { - seen.insert(skill.name.clone()); - matches.push(skill.clone()); - } - } - matches -} - fn normalize_skill_config_path(path: &Path) -> PathBuf { dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) } + +pub(crate) fn collect_tool_mentions( + text: &str, + mention_paths: &HashMap, +) -> ToolMentions { + let mut mentions = extract_tool_mentions_from_text(text); + for (name, path) in mention_paths { + if mentions.names.contains(name) { + mentions.linked_paths.insert(name.clone(), path.clone()); + } + } + mentions +} + +pub(crate) fn find_skill_mentions_with_tool_mentions( + mentions: &ToolMentions, + skills: &[SkillMetadata], +) -> Vec { + let mention_skill_paths: HashSet<&str> = mentions + .linked_paths + .values() + .filter(|path| is_skill_path(path)) + .map(|path| normalize_skill_path(path)) + .collect(); + + let mut seen_names = HashSet::new(); + let mut seen_paths = HashSet::new(); + let mut matches: Vec = Vec::new(); + + for skill in skills { + if seen_paths.contains(&skill.path) { + continue; + } + let path_str = skill.path.to_string_lossy(); + if mention_skill_paths.contains(path_str.as_ref()) { + seen_paths.insert(skill.path.clone()); + seen_names.insert(skill.name.clone()); + matches.push(skill.clone()); + } + } + + for skill in skills { + if seen_paths.contains(&skill.path) { + continue; + } + if mentions.names.contains(&skill.name) && seen_names.insert(skill.name.clone()) { + seen_paths.insert(skill.path.clone()); + matches.push(skill.clone()); + } + } + + matches +} + +pub(crate) fn find_app_mentions( + mentions: &ToolMentions, + apps: &[AppInfo], + skill_names_lower: &HashSet, +) -> Vec { + let mut explicit_names = HashSet::new(); + let mut selected_ids = HashSet::new(); + for (name, path) in &mentions.linked_paths { + if let Some(connector_id) = app_id_from_path(path) { + explicit_names.insert(name.clone()); + selected_ids.insert(connector_id.to_string()); + } + } + + let mut slug_counts: HashMap = HashMap::new(); + for app in apps { + let slug = connector_mention_slug(app); + *slug_counts.entry(slug).or_insert(0) += 1; + } + + for app in apps { + let slug = connector_mention_slug(app); + let slug_count = slug_counts.get(&slug).copied().unwrap_or(0); + if mentions.names.contains(&slug) + && !explicit_names.contains(&slug) + && slug_count == 1 + && !skill_names_lower.contains(&slug) + { + selected_ids.insert(app.id.clone()); + } + } + + apps.iter() + .filter(|app| selected_ids.contains(&app.id)) + .cloned() + .collect() +} + +pub(crate) struct ToolMentions { + names: HashSet, + linked_paths: HashMap, +} + +fn extract_tool_mentions_from_text(text: &str) -> ToolMentions { + let text_bytes = text.as_bytes(); + let mut names: HashSet = HashSet::new(); + let mut linked_paths: HashMap = HashMap::new(); + + let mut index = 0; + while index < text_bytes.len() { + let byte = text_bytes[index]; + if byte == b'[' + && let Some((name, path, end_index)) = + parse_linked_tool_mention(text, text_bytes, index) + { + if !is_common_env_var(name) { + if !is_app_or_mcp_path(path) { + names.insert(name.to_string()); + } + linked_paths + .entry(name.to_string()) + .or_insert(path.to_string()); + } + index = end_index; + continue; + } + + if byte != b'$' { + index += 1; + continue; + } + + let name_start = index + 1; + let Some(first_name_byte) = text_bytes.get(name_start) else { + index += 1; + continue; + }; + if !is_mention_name_char(*first_name_byte) { + index += 1; + continue; + } + + let mut name_end = name_start + 1; + while let Some(next_byte) = text_bytes.get(name_end) + && is_mention_name_char(*next_byte) + { + name_end += 1; + } + + let name = &text[name_start..name_end]; + if !is_common_env_var(name) { + names.insert(name.to_string()); + } + index = name_end; + } + + ToolMentions { + names, + linked_paths, + } +} + +fn parse_linked_tool_mention<'a>( + text: &'a str, + text_bytes: &[u8], + start: usize, +) -> Option<(&'a str, &'a str, usize)> { + let dollar_index = start + 1; + if text_bytes.get(dollar_index) != Some(&b'$') { + return None; + } + + let name_start = dollar_index + 1; + let first_name_byte = text_bytes.get(name_start)?; + if !is_mention_name_char(*first_name_byte) { + return None; + } + + let mut name_end = name_start + 1; + while let Some(next_byte) = text_bytes.get(name_end) + && is_mention_name_char(*next_byte) + { + name_end += 1; + } + + if text_bytes.get(name_end) != Some(&b']') { + return None; + } + + let mut path_start = name_end + 1; + while let Some(next_byte) = text_bytes.get(path_start) + && next_byte.is_ascii_whitespace() + { + path_start += 1; + } + if text_bytes.get(path_start) != Some(&b'(') { + return None; + } + + let mut path_end = path_start + 1; + while let Some(next_byte) = text_bytes.get(path_end) + && *next_byte != b')' + { + path_end += 1; + } + if text_bytes.get(path_end) != Some(&b')') { + return None; + } + + let path = text[path_start + 1..path_end].trim(); + if path.is_empty() { + return None; + } + + let name = &text[name_start..name_end]; + Some((name, path, path_end + 1)) +} + +fn is_common_env_var(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + matches!( + upper.as_str(), + "PATH" + | "HOME" + | "USER" + | "SHELL" + | "PWD" + | "TMPDIR" + | "TEMP" + | "TMP" + | "LANG" + | "TERM" + | "XDG_CONFIG_HOME" + ) +} + +fn is_mention_name_char(byte: u8) -> bool { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') +} + +fn is_skill_path(path: &str) -> bool { + !is_app_or_mcp_path(path) +} + +fn normalize_skill_path(path: &str) -> &str { + path.strip_prefix("skill://").unwrap_or(path) +} + +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 78fbc15b4..570a72750 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -355,6 +355,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { path: first_images[0].clone(), }], text_elements: first_elements, + mention_paths: HashMap::new(), }); chat.queued_user_messages.push_back(UserMessage { text: second_text, @@ -363,6 +364,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { path: second_images[0].clone(), }], text_elements: second_elements, + mention_paths: HashMap::new(), }); chat.refresh_queued_user_messages(); @@ -442,6 +444,7 @@ async fn remap_placeholders_uses_attachment_labels() { text, text_elements: elements, local_images: attachments, + mention_paths: HashMap::new(), }; let mut next_label = 3usize; let remapped = remap_placeholders_for_message(message, &mut next_label); @@ -502,6 +505,7 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { text, text_elements: elements, local_images: attachments, + mention_paths: HashMap::new(), }; let mut next_label = 3usize; let remapped = remap_placeholders_for_message(message, &mut next_label); @@ -810,6 +814,7 @@ async fn make_chatwidget_manual( unified_exec_processes: Vec::new(), agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), diff --git a/codex-rs/tui/src/skills_helpers.rs b/codex-rs/tui/src/skills_helpers.rs index 8332be886..1a8c78bb1 100644 --- a/codex-rs/tui/src/skills_helpers.rs +++ b/codex-rs/tui/src/skills_helpers.rs @@ -26,10 +26,6 @@ pub(crate) fn truncate_skill_name(name: &str) -> String { truncate_text(name, SKILL_NAME_TRUNCATE_LEN) } -pub(crate) fn truncated_skill_display_name(skill: &SkillMetadata) -> String { - truncate_skill_name(skill_display_name(skill)) -} - pub(crate) fn match_skill( filter: &str, display_name: &str, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 8a7a32066..5d1aa8dc4 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -33,6 +33,7 @@ pub enum SlashCommand { Mention, Status, Mcp, + Apps, Logout, Quit, Exit, @@ -69,6 +70,7 @@ impl SlashCommand { SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Experimental => "toggle experimental features", SlashCommand::Mcp => "list configured MCP tools", + SlashCommand::Apps => "manage apps", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -104,6 +106,7 @@ impl SlashCommand { | SlashCommand::Status | SlashCommand::Ps | SlashCommand::Mcp + | SlashCommand::Apps | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true,