From 4d25fe57b30e54cefee5ffee3dcf7301669704f6 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 21 Jan 2026 17:37:09 -0800 Subject: [PATCH] update --- codex-rs/Cargo.lock | 5 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 37 +++ codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 50 ++++ codex-rs/chatgpt/src/chatgpt_client.rs | 35 +++ codex-rs/chatgpt/src/connectors.rs | 118 +++++++++ codex-rs/chatgpt/src/lib.rs | 1 + codex-rs/core/config.schema.json | 14 +- codex-rs/core/src/auth.rs | 1 + codex-rs/core/src/codex.rs | 119 +++++++-- codex-rs/core/src/connectors.rs | 227 ++++++++++++++++++ codex-rs/core/src/features.rs | 8 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/mcp/mod.rs | 111 ++++++++- codex-rs/core/src/mcp_connection_manager.rs | 12 +- codex-rs/core/src/token_data.rs | 8 + codex-rs/rmcp-client/src/lib.rs | 2 + codex-rs/rmcp-client/src/rmcp_client.rs | 55 ++++- codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app_event.rs | 9 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 208 ++++++++++++---- codex-rs/tui/src/bottom_pane/command_popup.rs | 3 + codex-rs/tui/src/bottom_pane/mod.rs | 11 + codex-rs/tui/src/bottom_pane/skill_popup.rs | 77 +++--- codex-rs/tui/src/chatwidget.rs | 193 +++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/history_cell.rs | 8 + codex-rs/tui/src/slash_command.rs | 4 + codex-rs/tui2/Cargo.toml | 2 + codex-rs/tui2/src/app.rs | 6 + codex-rs/tui2/src/app_event.rs | 12 + .../tui2/src/bottom_pane/chat_composer.rs | 42 +++- .../tui2/src/bottom_pane/command_popup.rs | 3 + codex-rs/tui2/src/bottom_pane/mod.rs | 5 + codex-rs/tui2/src/chatwidget.rs | 177 +++++++++++++- codex-rs/tui2/src/chatwidget/tests.rs | 1 + codex-rs/tui2/src/history_cell.rs | 8 + codex-rs/tui2/src/slash_command.rs | 4 + docs/config.md | 13 + 42 files changed, 1476 insertions(+), 126 deletions(-) create mode 100644 codex-rs/chatgpt/src/connectors.rs create mode 100644 codex-rs/core/src/connectors.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7c5b5a8634..71f493ac27 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1005,6 +1005,7 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-backend-client", + "codex-chatgpt", "codex-common", "codex-core", "codex-feedback", @@ -1750,6 +1751,7 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-backend-client", + "codex-chatgpt", "codex-cli", "codex-common", "codex-core", @@ -1795,6 +1797,7 @@ dependencies = [ "tokio-stream", "tokio-util", "toml 0.9.5", + "toml_edit 0.24.0+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", @@ -1825,6 +1828,7 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-backend-client", + "codex-chatgpt", "codex-cli", "codex-common", "codex-core", @@ -1871,6 +1875,7 @@ dependencies = [ "tokio-stream", "tokio-util", "toml 0.9.5", + "toml_edit 0.24.0+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 911a4a0e9a..2bdfa7878b 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -133,6 +133,10 @@ client_request_definitions! { params: v2::SkillsListParams, response: v2::SkillsListResponse, }, + ConnectorsList => "connectors/list" { + params: v2::ConnectorsListParams, + response: v2::ConnectorsListResponse, + }, SkillsConfigWrite => "skills/config/write" { params: v2::SkillsConfigWriteParams, response: v2::SkillsConfigWriteResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 563a78cc6e..7af3bed922 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -962,6 +962,43 @@ pub struct ListMcpServerStatusResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConnectorsListParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConnectorInfo { + #[serde(rename = "id")] + pub connector_id: String, + #[serde(rename = "name")] + pub connector_name: String, + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "description" + )] + #[ts(optional)] + pub connector_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub logo_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub install_url: Option, + #[serde(default)] + pub is_accessible: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConnectorsListResponse { + pub data: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index fbe9150a1a..96a4be568a 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -22,6 +22,7 @@ codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } codex-backend-client = { workspace = true } codex-file-search = { workspace = true } +codex-chatgpt = { workspace = true } codex-login = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9bc8c3dff1..5fbc5c2afa 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -88,6 +88,7 @@ Example (from OpenAI's official VSCode extension): - `model/list` — list available models (with reasoning effort options). - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `connectors/list` — list available connectors. - `skills/config/write` — write user-level skill config by path. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index a83607a621..071cf4d911 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -26,6 +26,9 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::ConnectorInfo as ApiConnectorInfo; +use codex_app_server_protocol::ConnectorsListParams; +use codex_app_server_protocol::ConnectorsListResponse; use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::ExecOneOffCommandResponse; @@ -120,6 +123,7 @@ use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::UserSavedConfig; use codex_app_server_protocol::build_turns_from_event_msgs; use codex_backend_client::Client as BackendClient; +use codex_chatgpt::connectors; use codex_core::AuthManager; use codex_core::CodexThread; use codex_core::Cursor as RolloutCursor; @@ -405,6 +409,9 @@ impl CodexMessageProcessor { ClientRequest::SkillsList { request_id, params } => { self.skills_list(request_id, params).await; } + ClientRequest::ConnectorsList { request_id, params } => { + self.connectors_list(request_id, params).await; + } ClientRequest::SkillsConfigWrite { request_id, params } => { self.skills_config_write(request_id, params).await; } @@ -3263,6 +3270,49 @@ impl CodexMessageProcessor { .await; } + async fn connectors_list(&self, request_id: RequestId, params: ConnectorsListParams) { + let ConnectorsListParams {} = params; + let config = match self.load_latest_config().await { + Ok(config) => config, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + if !config.features.enabled(Feature::Connectors) { + self.outgoing + .send_response(request_id, ConnectorsListResponse { data: Vec::new() }) + .await; + return; + } + + let connectors = match connectors::list_connectors(&config).await { + Ok(connectors) => connectors, + Err(err) => { + self.send_internal_error(request_id, format!("failed to list connectors: {err}")) + .await; + return; + } + }; + + let data = connectors + .into_iter() + .map(|connector| ApiConnectorInfo { + connector_id: connector.connector_id, + connector_name: connector.connector_name, + connector_description: connector.connector_description, + logo_url: connector.logo_url, + install_url: connector.install_url, + is_accessible: connector.is_accessible, + }) + .collect(); + + self.outgoing + .send_response(request_id, ConnectorsListResponse { data }) + .await; + } + async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) { let SkillsListParams { cwds, force_reload } = params; let cwds = if cwds.is_empty() { diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 7528631981..b35238cdec 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -5,6 +5,7 @@ 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; /// Make a GET request to the ChatGPT backend API. @@ -48,3 +49,37 @@ pub(crate) async fn chatgpt_get_request( 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")?; + + 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}") + } +} diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs new file mode 100644 index 0000000000..947e8662fc --- /dev/null +++ b/codex-rs/chatgpt/src/connectors.rs @@ -0,0 +1,118 @@ +use codex_core::config::Config; +use serde::Deserialize; +use serde::Serialize; + +use crate::chatgpt_client::chatgpt_post_request; +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::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, +} + +pub async fn list_connectors(config: &Config) -> anyhow::Result> { + let (connectors_result, accessible_result) = tokio::join!( + list_all_connectors(config), + list_accessible_connectors_from_mcp_tools(config), + ); + let connectors = connectors_result?; + let accessible = accessible_result?; + Ok(merge_connectors(connectors, accessible)) +} + +pub async fn list_all_connectors(config: &Config) -> anyhow::Result> { + init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) + .await?; + + let token_data = + get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; + let user_id = token_data + .id_token + .chatgpt_user_id + .as_deref() + .ok_or_else(|| { + anyhow::anyhow!("ChatGPT user ID not available, please re-run `codex login`") + })?; + let account_id = token_data + .id_token + .chatgpt_account_id + .as_deref() + .ok_or_else(|| { + anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`") + })?; + let principal_id = format!("{user_id}__{account_id}"); + let request = ListConnectorsRequest { + principals: vec![Principal { + principal_type: PrincipalType::User, + id: principal_id, + }], + }; + let response: ListConnectorsResponse = chatgpt_post_request( + config, + token_data.access_token.as_str(), + account_id, + "/aip/connectors/list_accessible?skip_actions=true&external_logos=true", + &request, + ) + .await?; + let mut connectors = response.connectors; + 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), + }; + connector.connector_name = + normalize_connector_name(&connector.connector_name, &connector.connector_id); + connector.connector_description = + normalize_connector_value(connector.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)) + }); + Ok(connectors) +} + +fn normalize_connector_name(name: &str, connector_id: &str) -> String { + let trimmed = name.trim(); + if trimmed.is_empty() { + connector_id.to_string() + } else { + trimmed.to_string() + } +} + +fn normalize_connector_value(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} diff --git a/codex-rs/chatgpt/src/lib.rs b/codex-rs/chatgpt/src/lib.rs index 440a309db6..0d39bb932d 100644 --- a/codex-rs/chatgpt/src/lib.rs +++ b/codex-rs/chatgpt/src/lib.rs @@ -1,4 +1,5 @@ pub mod apply_command; mod chatgpt_client; mod chatgpt_token; +pub mod connectors; pub mod get_task; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bfb54821fc..1f39f27c61 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -84,6 +84,9 @@ "collaboration_modes": { "type": "boolean" }, + "connectors": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, @@ -359,12 +362,12 @@ }, "shell_environment_policy": { "default": { - "inherit": null, - "ignore_default_excludes": null, "exclude": null, - "set": null, + "experimental_use_profile": null, + "ignore_default_excludes": null, "include_only": null, - "experimental_use_profile": null + "inherit": null, + "set": null }, "allOf": [ { @@ -563,6 +566,9 @@ "collaboration_modes": { "type": "boolean" }, + "connectors": { + "type": "boolean" + }, "elevated_windows_sandbox": { "type": "boolean" }, diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 523c77388d..b746309270 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -996,6 +996,7 @@ mod tests { id_token: IdTokenInfo { email: Some("user@example.com".to_string()), chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)), + chatgpt_user_id: Some("user-12345".to_string()), chatgpt_account_id: None, raw_jwt: fake_jwt, }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5ca971c121..fc46f57f11 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::collections::HashSet; use std::fmt::Debug; use std::path::PathBuf; use std::sync::Arc; @@ -17,6 +18,7 @@ use crate::compact; use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; +use crate::connectors; use crate::exec_policy::ExecPolicyManager; use crate::features::Feature; use crate::features::Features; @@ -101,7 +103,10 @@ use crate::exec::StreamOutput; use crate::exec_policy::ExecPolicyUpdateError; use crate::feedback_tags; use crate::instructions::UserInstructions; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::compute_auth_statuses; +use crate::mcp::effective_mcp_servers; +use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; use crate::project_doc::get_user_instructions; @@ -617,14 +622,25 @@ impl Session { let rollout_fut = RolloutRecorder::new(&config, rollout_params); let history_meta_fut = crate::message_history::history_metadata(&config); - let auth_statuses_fut = compute_auth_statuses( - config.mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - ); + let auth_manager_clone = Arc::clone(&auth_manager); + let config_for_mcp = Arc::clone(&config); + let auth_and_mcp_fut = async move { + let auth = auth_manager_clone.auth().await; + let mcp_servers = effective_mcp_servers(&config_for_mcp, auth.as_ref()); + let auth_statuses = compute_auth_statuses( + mcp_servers.iter(), + config_for_mcp.mcp_oauth_credentials_store_mode, + ) + .await; + (auth, mcp_servers, auth_statuses) + }; // Join all independent futures. - let (rollout_recorder, (history_log_id, history_entry_count), auth_statuses) = - tokio::join!(rollout_fut, history_meta_fut, auth_statuses_fut); + let ( + rollout_recorder, + (history_log_id, history_entry_count), + (auth, mcp_servers, auth_statuses), + ) = tokio::join!(rollout_fut, history_meta_fut, auth_and_mcp_fut); let rollout_recorder = rollout_recorder.map_err(|e| { error!("failed to initialize rollout recorder: {e:#}"); @@ -651,7 +667,6 @@ impl Session { } maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events); - let auth = auth_manager.auth().await; let auth = auth.as_ref(); let otel_manager = OtelManager::new( conversation_id, @@ -686,7 +701,7 @@ impl Session { config.model_auto_compact_token_limit, config.approval_policy.value(), config.sandbox_policy.get().clone(), - config.mcp_servers.keys().map(String::as_str).collect(), + mcp_servers.keys().map(String::as_str).collect(), config.active_profile.clone(), ); @@ -768,7 +783,7 @@ impl Session { .write() .await .initialize( - &config.mcp_servers, + &mcp_servers, config.mcp_oauth_credentials_store_mode, auth_statuses.clone(), tx_event.clone(), @@ -1891,6 +1906,12 @@ impl Session { } }; + let auth = self.services.auth_manager.auth().await; + let mcp_servers = with_codex_apps_mcp( + mcp_servers, + self.features.enabled(Feature::Connectors), + auth.as_ref(), + ); let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await; let sandbox_state = SandboxState { sandbox_policy: turn_context.sandbox_policy.clone(), @@ -2070,6 +2091,7 @@ mod handlers { use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; + use crate::mcp::effective_mcp_servers; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; @@ -2381,13 +2403,12 @@ mod handlers { pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; + let auth = sess.services.auth_manager.auth().await; + let mcp_servers = effective_mcp_servers(config, auth.as_ref()); let snapshot = collect_mcp_snapshot_from_manager( &mcp_connection_manager, - compute_auth_statuses( - config.mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - ) - .await, + compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode) + .await, ) .await; let event = Event { @@ -2896,6 +2917,60 @@ async fn run_auto_compact(sess: &Arc, turn_context: &Arc) } } +fn filter_connectors_for_input( + connectors: Vec, + input: &[ResponseItem], +) -> Vec { + let user_messages = collect_user_messages(input); + if user_messages.is_empty() { + return Vec::new(); + } + + connectors + .into_iter() + .filter(|connector| connector_inserted_in_messages(connector, &user_messages)) + .collect() +} + +fn connector_inserted_in_messages( + connector: &connectors::ConnectorInfo, + user_messages: &[String], +) -> 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) + }) +} + +fn filter_codex_apps_mcp_tools( + mut mcp_tools: HashMap, + connectors: &[connectors::ConnectorInfo], +) -> HashMap { + let allowed: HashSet<&str> = connectors + .iter() + .map(|connector| connector.connector_id.as_str()) + .collect(); + + mcp_tools.retain(|_, tool| { + if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { + return true; + } + let Some(connector_id) = codex_apps_connector_id(tool) else { + return false; + }; + allowed.contains(connector_id) + }); + + mcp_tools +} + +fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Option<&str> { + tool.connector_id.as_deref() +} + #[instrument(level = "trace", skip_all, fields( @@ -2912,7 +2987,7 @@ async fn run_sampling_request( input: Vec, cancellation_token: CancellationToken, ) -> CodexResult { - let mcp_tools = sess + let mut mcp_tools = sess .services .mcp_connection_manager .read() @@ -2920,6 +2995,20 @@ 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 = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + Some(filter_connectors_for_input(connectors, &input)) + } else { + None + }; + if let Some(connectors) = connectors_for_tools.as_ref() { + mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, connectors); + } let router = Arc::new(ToolRouter::from_config( &turn_context.tools_config, Some( diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs new file mode 100644 index 0000000000..c39196a518 --- /dev/null +++ b/codex-rs/core/src/connectors.rs @@ -0,0 +1,227 @@ +use std::collections::HashMap; +use std::env; +use std::path::PathBuf; + +use async_channel::unbounded; +use codex_protocol::protocol::SandboxPolicy; +use serde::Deserialize; +use serde::Serialize; +use tokio_util::sync::CancellationToken; + +use crate::AuthManager; +use crate::SandboxState; +use crate::config::Config; +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::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) { + return Ok(Vec::new()); + } + + let auth_manager = auth_manager_from_config(config); + let auth = auth_manager.auth().await; + let mcp_servers = with_codex_apps_mcp(HashMap::new(), true, auth.as_ref()); + if mcp_servers.is_empty() { + return Ok(Vec::new()); + } + + let auth_status_entries = + compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await; + + let mut mcp_connection_manager = McpConnectionManager::default(); + let (tx_event, rx_event) = unbounded(); + drop(rx_event); + let cancel_token = CancellationToken::new(); + + let sandbox_state = SandboxState { + sandbox_policy: SandboxPolicy::ReadOnly, + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")), + }; + + mcp_connection_manager + .initialize( + &mcp_servers, + config.mcp_oauth_credentials_store_mode, + auth_status_entries, + tx_event, + cancel_token.clone(), + sandbox_state, + ) + .await; + + let tools = mcp_connection_manager.list_all_tools().await; + cancel_token.cancel(); + + Ok(accessible_connectors_from_mcp_tools(&tools)) +} + +fn auth_manager_from_config(config: &Config) -> std::sync::Arc { + AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ) +} + +pub fn connector_display_label(connector: &ConnectorInfo) -> String { + format_connector_label(&connector.connector_name, &connector.connector_id) +} + +pub(crate) fn accessible_connectors_from_mcp_tools( + mcp_tools: &HashMap, +) -> Vec { + let tools = mcp_tools.values().filter_map(|tool| { + if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + let connector_id = tool.connector_id.as_deref()?; + let connector_name = normalize_connector_value(tool.connector_name.as_deref()); + Some((connector_id.to_string(), connector_name)) + }); + collect_accessible_connectors(tools) +} + +pub fn merge_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) + }) + .collect(); + + for mut connector in accessible_connectors { + connector.is_accessible = true; + let connector_id = connector.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.connector_description.is_none() && connector.connector_description.is_some() + { + existing.connector_description = connector.connector_description; + } + if existing.logo_url.is_none() && connector.logo_url.is_some() { + existing.logo_url = connector.logo_url; + } + } else { + merged.insert(connector_id, connector); + } + } + + 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, + )); + } + } + 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)) + }); + merged +} + +fn collect_accessible_connectors(tools: I) -> Vec +where + I: IntoIterator)>, +{ + let mut connectors: HashMap = HashMap::new(); + for (connector_id, connector_name) in tools { + let connector_name = connector_name.unwrap_or_else(|| connector_id.clone()); + if let Some(existing_name) = connectors.get_mut(&connector_id) { + if existing_name == &connector_id && connector_name != connector_id { + *existing_name = connector_name; + } + } else { + connectors.insert(connector_id, connector_name); + } + } + 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, + logo_url: None, + is_accessible: true, + }) + .collect(); + accessible.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)) + }); + accessible +} + +fn normalize_connector_value(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +pub fn connector_install_url(name: &str, connector_id: &str) -> String { + let slug = connector_name_slug(name); + format!("https://chatgpt.com/apps/{slug}/{connector_id}") +} + +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() { + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push('-'); + } + } + let normalized = normalized.trim_matches('-'); + if normalized.is_empty() { + "app".to_string() + } else { + normalized.to_string() + } +} + +fn format_connector_label(name: &str, _id: &str) -> String { + name.to_string() +} diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 3e95c566bd..fcdfc20fee 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -103,6 +103,8 @@ pub enum Feature { EnableRequestCompression, /// Enable collab tools. Collab, + /// Enable connectors (apps). + Connectors, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. Steer, /// Enable collaboration modes (Plan, Pair Programming, Execute). @@ -435,6 +437,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Beta, default_enabled: false, }, + FeatureSpec { + id: Feature::Connectors, + key: "connectors", + stage: Stage::Beta, + default_enabled: false, + }, FeatureSpec { id: Feature::Tui2, key: "tui2", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index a23ae55aa8..186de87e43 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -20,6 +20,7 @@ mod codex_delegate; mod command_safety; pub mod config; pub mod config_loader; +pub mod connectors; mod context_manager; pub mod custom_prompts; pub mod env; diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 9e5446a743..93c47844d4 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -9,16 +9,116 @@ use codex_protocol::protocol::SandboxPolicy; use mcp_types::Tool as McpTool; use tokio_util::sync::CancellationToken; +use crate::AuthManager; +use crate::CodexAuth; use crate::config::Config; +use crate::config::types::McpServerConfig; +use crate::config::types::McpServerTransportConfig; +use crate::features::Feature; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; 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"; +const CODEX_APPS_MCP_URL: &str = "http://localhost:10000/backend-api/wham/apps"; +const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN"; + +fn codex_apps_mcp_bearer_token_env_var() -> Option { + match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) { + Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), + Ok(_) => None, + Err(env::VarError::NotPresent) => None, + Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()), + } +} + +fn codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option { + let token = auth.and_then(|auth| auth.get_token().ok())?; + let token = token.trim(); + if token.is_empty() { + None + } else { + Some(token.to_string()) + } +} + +fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option> { + let mut headers = HashMap::new(); + if let Some(token) = codex_apps_mcp_bearer_token(auth) { + headers.insert("Authorization".to_string(), format!("Bearer {token}")); + } + if let Some(account_id) = auth.and_then(CodexAuth::get_account_id) { + headers.insert("ChatGPT-Account-ID".to_string(), account_id); + } + if headers.is_empty() { + None + } else { + Some(headers) + } +} + +fn codex_apps_mcp_server_config(auth: Option<&CodexAuth>) -> McpServerConfig { + let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var(); + let http_headers = if bearer_token_env_var.is_some() { + None + } else { + codex_apps_mcp_http_headers(auth) + }; + + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: CODEX_APPS_MCP_URL.to_string(), + bearer_token_env_var, + http_headers, + env_http_headers: None, + }, + enabled: true, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + } +} + +pub(crate) fn with_codex_apps_mcp( + mut servers: HashMap, + connectors_enabled: bool, + auth: Option<&CodexAuth>, +) -> HashMap { + if connectors_enabled { + servers.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + codex_apps_mcp_server_config(auth), + ); + } else { + servers.remove(CODEX_APPS_MCP_SERVER_NAME); + } + servers +} + +pub(crate) fn effective_mcp_servers( + config: &Config, + auth: Option<&CodexAuth>, +) -> HashMap { + with_codex_apps_mcp( + config.mcp_servers.get().clone(), + config.features.enabled(Feature::Connectors), + auth, + ) +} pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent { - if config.mcp_servers.is_empty() { + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); + let auth = auth_manager.auth().await; + let mcp_servers = effective_mcp_servers(config, auth.as_ref()); + if mcp_servers.is_empty() { return McpListToolsResponseEvent { tools: HashMap::new(), resources: HashMap::new(), @@ -27,11 +127,8 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent }; } - let auth_status_entries = compute_auth_statuses( - config.mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - ) - .await; + let auth_status_entries = + compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await; let mut mcp_connection_manager = McpConnectionManager::default(); let (tx_event, rx_event) = unbounded(); @@ -47,7 +144,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent mcp_connection_manager .initialize( - &config.mcp_servers, + &mcp_servers, config.mcp_oauth_credentials_store_mode, auth_status_entries.clone(), tx_event, diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 0d638760f5..434db3b2fa 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -153,6 +153,8 @@ pub(crate) struct ToolInfo { pub(crate) server_name: String, pub(crate) tool_name: String, pub(crate) tool: Tool, + pub(crate) connector_id: Option, + pub(crate) connector_name: Option, } type ResponderMap = HashMap<(String, RequestId), oneshot::Sender>; @@ -899,14 +901,16 @@ async fn list_tools_for_client( client: &Arc, timeout: Option, ) -> Result> { - let resp = client.list_tools(None, timeout).await?; + let resp = client.list_tools_with_connector_ids(None, timeout).await?; Ok(resp .tools .into_iter() .map(|tool| ToolInfo { server_name: server_name.to_owned(), - tool_name: tool.name.clone(), - tool, + tool_name: tool.tool.name.clone(), + tool: tool.tool, + connector_id: tool.connector_id, + connector_name: tool.connector_name, }) .collect()) } @@ -1004,6 +1008,8 @@ mod tests { output_schema: None, title: None, }, + connector_id: None, + connector_name: None, } } diff --git a/codex-rs/core/src/token_data.rs b/codex-rs/core/src/token_data.rs index 0a7694e9f3..526e240a6c 100644 --- a/codex-rs/core/src/token_data.rs +++ b/codex-rs/core/src/token_data.rs @@ -28,6 +28,8 @@ pub struct IdTokenInfo { /// (e.g., "free", "plus", "pro", "business", "enterprise", "edu"). /// (Note: values may vary by backend.) pub(crate) chatgpt_plan_type: Option, + /// ChatGPT user identifier associated with the token, if present. + pub chatgpt_user_id: Option, /// Organization/workspace identifier associated with the token, if present. pub chatgpt_account_id: Option, pub raw_jwt: String, @@ -74,6 +76,10 @@ struct AuthClaims { #[serde(default)] chatgpt_plan_type: Option, #[serde(default)] + chatgpt_user_id: Option, + #[serde(default)] + user_id: Option, + #[serde(default)] chatgpt_account_id: Option, } @@ -103,12 +109,14 @@ pub fn parse_id_token(id_token: &str) -> Result { email: claims.email, raw_jwt: id_token.to_string(), chatgpt_plan_type: auth.chatgpt_plan_type, + chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id), chatgpt_account_id: auth.chatgpt_account_id, }), None => Ok(IdTokenInfo { email: claims.email, raw_jwt: id_token.to_string(), chatgpt_plan_type: None, + chatgpt_user_id: None, chatgpt_account_id: None, }), } diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 954898cea4..e4d1f3b9f0 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -22,5 +22,7 @@ pub use perform_oauth_login::perform_oauth_login_return_url; pub use rmcp::model::ElicitationAction; pub use rmcp_client::Elicitation; pub use rmcp_client::ElicitationResponse; +pub use rmcp_client::ListToolsWithConnectorIdResult; pub use rmcp_client::RmcpClient; pub use rmcp_client::SendElicitation; +pub use rmcp_client::ToolWithConnectorId; diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index b977389eab..c1bf6d39d3 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -23,6 +23,7 @@ use mcp_types::ListToolsResult; use mcp_types::ReadResourceRequestParams; use mcp_types::ReadResourceResult; use mcp_types::RequestId; +use mcp_types::Tool; use reqwest::header::HeaderMap; use rmcp::model::CallToolRequestParam; use rmcp::model::ClientNotification; @@ -44,6 +45,7 @@ use rmcp::transport::auth::AuthClient; use rmcp::transport::auth::OAuthState; use rmcp::transport::child_process::TokioChildProcess; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; +use serde_json::Value; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Command; @@ -95,6 +97,17 @@ pub type SendElicitation = Box< dyn Fn(RequestId, Elicitation) -> BoxFuture<'static, Result> + Send + Sync, >; +pub struct ToolWithConnectorId { + pub tool: Tool, + pub connector_id: Option, + pub connector_name: Option, +} + +pub struct ListToolsWithConnectorIdResult { + pub next_cursor: Option, + pub tools: Vec, +} + /// MCP client implemented on top of the official `rmcp` SDK. /// https://github.com/modelcontextprotocol/rust-sdk pub struct RmcpClient { @@ -286,6 +299,18 @@ impl RmcpClient { params: Option, timeout: Option, ) -> Result { + let result = self.list_tools_with_connector_ids(params, timeout).await?; + Ok(ListToolsResult { + next_cursor: result.next_cursor, + tools: result.tools.into_iter().map(|tool| tool.tool).collect(), + }) + } + + pub async fn list_tools_with_connector_ids( + &self, + params: Option, + timeout: Option, + ) -> Result { self.refresh_oauth_if_needed().await; let service = self.service().await?; let rmcp_params = params @@ -294,9 +319,35 @@ impl RmcpClient { let fut = service.list_tools(rmcp_params); let result = run_with_timeout(fut, timeout, "tools/list").await?; - let converted = convert_to_mcp(result)?; + let tools = result + .tools + .into_iter() + .map(|tool| { + let meta = tool.meta.as_ref(); + let connector_id = Self::meta_string(meta, "connector_id"); + let connector_name = Self::meta_string(meta, "connector_name") + .or_else(|| Self::meta_string(meta, "connector_display_name")); + let tool = convert_to_mcp(tool)?; + Ok(ToolWithConnectorId { + tool, + connector_id, + connector_name, + }) + }) + .collect::>>()?; self.persist_oauth_tokens().await; - Ok(converted) + Ok(ListToolsWithConnectorIdResult { + next_cursor: result.next_cursor, + tools, + }) + } + + fn meta_string(meta: Option<&rmcp::model::Meta>, key: &str) -> Option { + meta.and_then(|meta| meta.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) } pub async fn list_resources( diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 9b7782edf2..74b95be514 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", @@ -83,6 +84,7 @@ tokio = { workspace = true, features = [ ] } tokio-stream = { workspace = true, features = ["sync"] } toml = { workspace = true } +toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 211975c275..057eae4c11 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -976,6 +976,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 f34e1d7d5a..a057150ea0 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::ConnectorInfo; use codex_common::approval_presets::ApprovalPreset; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; @@ -38,6 +39,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 { @@ -87,6 +93,9 @@ 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), diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3ab9071347..8b545559a4 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/connector 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). @@ -92,6 +92,7 @@ use super::footer::reset_mode_after_activity; 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 crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::expand_custom_prompt; @@ -112,6 +113,7 @@ use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use codex_protocol::models::local_image_label_text; use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; @@ -119,6 +121,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::ConnectorInfo; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -197,10 +201,12 @@ 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, /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, collaboration_modes_enabled: bool, + connectors_enabled: bool, } #[derive(Clone, Debug)] @@ -258,9 +264,11 @@ 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, steer_enabled: false, collaboration_modes_enabled: false, + connectors_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -271,6 +279,10 @@ impl ChatComposer { self.skills = skills; } + pub fn set_connector_mentions(&mut self, connectors_snapshot: Option) { + self.connectors_snapshot = connectors_snapshot; + } + /// Enables or disables "Steer" behavior for submission keys. /// /// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and @@ -285,6 +297,10 @@ impl ChatComposer { self.collaboration_modes_enabled = enabled; } + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.connectors_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -1097,8 +1113,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) @@ -1111,9 +1127,11 @@ 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); + let selected = popup + .selected_mention() + .map(|mention| mention.insert_text.clone()); + if let Some(insert_text) = selected { + self.insert_selected_mention(&insert_text); } self.active_popup = ActivePopup::None; (InputResult::None, true) @@ -1135,6 +1153,19 @@ impl ChatComposer { 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. @@ -1248,8 +1279,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) @@ -1306,7 +1337,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); @@ -1327,7 +1358,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); @@ -1376,9 +1407,11 @@ impl ChatComposer { if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .any(|(command_name, _)| command_name == name); + let is_builtin = Self::built_in_slash_commands_for_input( + self.collaboration_modes_enabled, + self.connectors_enabled, + ) + .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1495,9 +1528,11 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .find(|(n, _)| *n == name) + && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( + self.collaboration_modes_enabled, + self.connectors_enabled, + ) + .find(|(n, _)| *n == name) { self.textarea.set_text(""); Some(InputResult::Command(cmd)) @@ -1517,9 +1552,11 @@ impl ChatComposer { if let Some((name, rest)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') - && let Some((_n, cmd)) = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .find(|(command_name, _)| *command_name == name) + && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( + self.collaboration_modes_enabled, + self.connectors_enabled, + ) + .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { self.textarea.set_text(""); @@ -1908,22 +1945,22 @@ 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 = file_token.is_none() && skill_token.is_none(); + let allow_command_popup = file_token.is_none() && mention_token.is_none(); self.sync_command_popup(allow_command_popup); if matches!(self.active_popup, ActivePopup::Command(_)) { 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 { - self.sync_skill_popup(token); + if let Some(token) = mention_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); @@ -1975,9 +2012,11 @@ impl ChatComposer { return rest_after_name.is_empty(); } - let builtin_match = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); + let builtin_match = Self::built_in_slash_commands_for_input( + self.collaboration_modes_enabled, + self.connectors_enabled, + ) + .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { return true; @@ -2031,11 +2070,13 @@ impl ChatComposer { if is_editing_slash_command_name { let skills_enabled = self.skills_enabled(); let collaboration_modes_enabled = self.collaboration_modes_enabled; + let connectors_enabled = self.connectors_enabled; let mut command_popup = CommandPopup::new( self.custom_prompts.clone(), CommandPopupFlags { skills_enabled, collaboration_modes_enabled, + connectors_enabled, }, ); command_popup.on_composer_text_change(first_line.to_string()); @@ -2047,12 +2088,14 @@ impl ChatComposer { fn built_in_slash_commands_for_input( collaboration_modes_enabled: bool, + connectors_enabled: bool, ) -> impl Iterator { let allow_elevate_sandbox = windows_degraded_sandbox_active(); built_in_slash_commands() .into_iter() .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(move |(_, cmd)| connectors_enabled || *cmd != SlashCommand::Connectors) } pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { @@ -2098,32 +2141,95 @@ 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, + }); + } + } + + 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 search_terms = vec![display_name.clone(), connector.connector_id.clone()]; + mentions.push(MentionItem { + display_name: display_name.clone(), + description, + insert_text: display_name.clone(), + search_terms, + }); + } + } + + mentions + } + + fn connector_brief_description(connector: &ConnectorInfo) -> 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: &ConnectorInfo) -> Option { + connector + .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; } @@ -2162,6 +2268,25 @@ 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()) +} + impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if !self.input_enabled { @@ -2322,6 +2447,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 83afdca30c..88224e06e2 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -41,6 +41,7 @@ pub(crate) struct CommandPopup { pub(crate) struct CommandPopupFlags { pub(crate) skills_enabled: bool, pub(crate) collaboration_modes_enabled: bool, + pub(crate) connectors_enabled: bool, } impl CommandPopup { @@ -51,6 +52,7 @@ impl CommandPopup { .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Connectors) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); @@ -448,6 +450,7 @@ mod tests { CommandPopupFlags { skills_enabled: false, collaboration_modes_enabled: true, + connectors_enabled: false, }, ); popup.on_composer_text_change("/collab".to_string()); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8668367aa3..3ba7d4a361 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -15,6 +15,7 @@ //! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle. 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; @@ -185,6 +186,11 @@ 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 set_steer_enabled(&mut self, enabled: bool) { self.composer.set_steer_enabled(enabled); } @@ -194,6 +200,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.composer.set_connectors_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 867c10a8bf..427e7369a2 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -15,27 +15,34 @@ use crate::key_hint; use crate::render::Insets; use crate::render::RectExt; use codex_common::fuzzy_match::fuzzy_match; -use codex_core::skills::model::SkillMetadata; use crate::text_formatting::truncate_text; +#[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) 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(); } @@ -62,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) { @@ -86,14 +93,14 @@ impl SkillPopup { matches .into_iter() .map(|(idx, indices, _score)| { - let skill = &self.skills[idx]; - let name = truncate_text(skill_display_name(skill), 21); - 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()), disabled_reason: None, wrap_indent: None, } @@ -106,27 +113,34 @@ 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); + for (idx, mention) in self.mentions.iter().enumerate() { + let display_name = mention.display_name.as_str(); if let Some((indices, score)) = fuzzy_match(display_name, filter) { out.push((idx, Some(indices), score)); - } else if display_name != skill.name - && let Some((_indices, score)) = fuzzy_match(&skill.name, filter) - { + continue; + } + + let mut best_score: Option = None; + for term in &mention.search_terms { + if let Some((_indices, score)) = fuzzy_match(term, filter) { + best_score = Some(best_score.map_or(score, |best| best.max(score))); + } + } + if let Some(score) = best_score { out.push((idx, None, 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) }) }); @@ -155,7 +169,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 { @@ -173,25 +187,8 @@ 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(), ]) } - -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) -> &str { - skill - .interface - .as_ref() - .and_then(|interface| interface.short_description.as_deref()) - .or(skill.short_description.as_deref()) - .unwrap_or(&skill.description) -} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c6870ca320..b38feb631e 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; @@ -115,6 +116,7 @@ use tracing::debug; const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; @@ -364,6 +366,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] @@ -437,6 +448,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 @@ -608,6 +620,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 = Some(event.rollout_path.clone()); @@ -632,6 +645,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); } @@ -1704,6 +1720,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(), @@ -1734,6 +1751,9 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget + .bottom_pane + .set_connectors_enabled(widget.config.features.enabled(Feature::Connectors)); widget } @@ -1807,6 +1827,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(), @@ -1837,6 +1858,9 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget + .bottom_pane + .set_connectors_enabled(widget.config.features.enabled(Feature::Connectors)); widget } @@ -2145,6 +2169,9 @@ impl ChatWidget { SlashCommand::Mcp => { self.add_mcp_output(); } + SlashCommand::Connectors => { + self.add_connectors_output(); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -2733,6 +2760,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(); @@ -3966,6 +4015,17 @@ impl ChatWidget { self.bottom_pane.set_steer_enabled(enabled); } else if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); + } else if feature == Feature::Connectors { + self.bottom_pane.set_connectors_enabled(enabled); + if enabled { + self.prefetch_connectors(); + 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); + } } } @@ -4036,6 +4096,10 @@ impl ChatWidget { self.config.features.enabled(Feature::CollaborationModes) } + fn connectors_enabled(&self) -> bool { + self.config.features.enabled(Feature::Connectors) + } + fn model_display_name(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) } @@ -4103,6 +4167,122 @@ 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 connectors feature to use $ or /apps.".to_string()), + ); + return; + } + + if matches!(self.connectors_cache, ConnectorsCacheState::Uninitialized) { + self.prefetch_connectors(); + } + let connectors_snapshot = match &self.connectors_cache { + ConnectorsCacheState::Ready(snapshot) => Some(snapshot.clone()), + ConnectorsCacheState::Failed(err) => { + self.add_to_history(history_cell::new_error_event(err.clone())); + None + } + ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { + self.add_to_history(history_cell::new_info_event( + "Apps are still loading.".to_string(), + Some("Try again in a moment.".to_string()), + )); + None + } + }; + if let Some(snapshot) = connectors_snapshot { + if snapshot.connectors.is_empty() { + self.add_info_message("No apps available.".to_string(), None); + } else { + self.open_connectors_popup(&snapshot.connectors); + } + } + self.request_redraw(); + } + + fn open_connectors_popup(&mut self, connectors: &[connectors::ConnectorInfo]) { + let mut items: Vec = Vec::with_capacity(connectors.len()); + for connector in connectors { + let connector_label = connectors::connector_display_label(connector); + let description = Self::connector_brief_description(connector); + let search_value = format!("{connector_label} {}", connector.connector_id); + let mut item = SelectionItem { + name: connector_label, + description: Some(description), + search_value: Some(search_value), + ..Default::default() + }; + if !connector.is_accessible { + if let Some(install_url) = connector.install_url.clone() { + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_install_instructions_event(install_url.clone()), + ))); + })]; + item.dismiss_on_select = true; + item.selected_description = + Some("Press Enter to view the install link.".to_string()); + } else { + item.actions = vec![Box::new(|tx| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + "Install link unavailable.".to_string(), + None, + ), + ))); + })]; + item.dismiss_on_select = true; + item.selected_description = Some("Install link unavailable.".to_string()); + } + } + items.push(item); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Apps".to_string()), + subtitle: Some( + "Browse apps. Select an unconnected app to view its install link.".to_string(), + ), + 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![ + "Use $ to insert apps. Press ".into(), + key_hint::plain(KeyCode::Esc).into(), + " to close.".into(), + ]) + } + + fn connector_brief_description(connector: &connectors::ConnectorInfo) -> 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::ConnectorInfo) -> Option { + connector + .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); @@ -4272,6 +4452,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/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1be07ed95e..a226d20db8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -425,6 +425,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/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b0bdbdf147..07c036c373 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1516,6 +1516,14 @@ pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHist PlainHistoryCell { lines } } +pub(crate) fn new_install_instructions_event(install_url: String) -> PrefixedWrappedHistoryCell { + let instructions = Line::from("Install this app in your browser, then reload Codex."); + let open_label = Line::from(vec!["Open:".dim()]); + let url_line = Line::from(vec![install_url.cyan().underlined()]); + let text = Text::from(vec![instructions, open_label, url_line]); + PrefixedWrappedHistoryCell::new(text, "• ".dim(), " ") +} + pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { // Use a hair space (U+200A) to create a subtle, near-invisible separation // before the text. VS16 is intentionally omitted to keep spacing tighter diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ab63d6c7c8..def31c991d 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -30,6 +30,8 @@ pub enum SlashCommand { Mention, Status, Mcp, + #[strum(serialize = "apps")] + Connectors, Logout, Quit, Exit, @@ -63,6 +65,7 @@ impl SlashCommand { SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Experimental => "toggle beta features", SlashCommand::Mcp => "list configured MCP tools", + SlashCommand::Connectors => "browse app connectors", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -96,6 +99,7 @@ impl SlashCommand { | SlashCommand::Status | SlashCommand::Ps | SlashCommand::Mcp + | SlashCommand::Connectors | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, diff --git a/codex-rs/tui2/Cargo.toml b/codex-rs/tui2/Cargo.toml index 391d216999..6abc7c1a4a 100644 --- a/codex-rs/tui2/Cargo.toml +++ b/codex-rs/tui2/Cargo.toml @@ -31,6 +31,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", @@ -86,6 +87,7 @@ tokio = { workspace = true, features = [ ] } tokio-stream = { workspace = true } toml = { workspace = true } +toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index de2fe20010..f7cade65a1 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1694,6 +1694,12 @@ 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::InsertComposerText(text) => { + self.chat_widget.insert_composer_text(&text); + } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); } diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index b2a70cae19..bef0ec2168 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -10,6 +10,7 @@ use std::path::PathBuf; +use codex_chatgpt::connectors::ConnectorInfo; use codex_common::approval_presets::ApprovalPreset; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; @@ -36,6 +37,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 { @@ -81,6 +87,12 @@ pub(crate) enum AppEvent { /// Result of refreshing rate limits RateLimitSnapshotFetched(RateLimitSnapshot), + /// Result of prefetching connectors. + ConnectorsLoaded(Result), + + /// Insert text into the chat composer. + InsertComposerText(String), + /// Result of computing a `/diff` command. DiffResult(String), diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 541806ec47..9f6b465510 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -207,6 +207,7 @@ pub(crate) struct ChatComposer { /// When enabled, `Enter` submits immediately and `Tab` requests queuing behavior. steer_enabled: bool, collaboration_modes_enabled: bool, + connectors_enabled: bool, } #[derive(Clone, Debug)] @@ -272,6 +273,7 @@ impl ChatComposer { dismissed_skill_popup_token: None, steer_enabled: false, collaboration_modes_enabled: false, + connectors_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -296,6 +298,10 @@ impl ChatComposer { self.collaboration_modes_enabled = enabled; } + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.connectors_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -1309,9 +1315,11 @@ impl ChatComposer { if let Some((name, _rest)) = parse_slash_name(&text) { let treat_as_plain_text = input_starts_with_space || name.contains('/'); if !treat_as_plain_text { - let is_builtin = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .any(|(command_name, _)| command_name == name); + let is_builtin = Self::built_in_slash_commands_for_input( + self.collaboration_modes_enabled, + self.connectors_enabled, + ) + .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name .strip_prefix(&prompt_prefix) @@ -1428,9 +1436,11 @@ impl ChatComposer { let first_line = self.textarea.text().lines().next().unwrap_or(""); if let Some((name, rest)) = parse_slash_name(first_line) && rest.is_empty() - && let Some((_n, cmd)) = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .find(|(n, _)| *n == name) + && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( + self.collaboration_modes_enabled, + self.connectors_enabled, + ) + .find(|(n, _)| *n == name) { self.textarea.set_text(""); Some(InputResult::Command(cmd)) @@ -1450,9 +1460,11 @@ impl ChatComposer { if let Some((name, rest)) = parse_slash_name(&text) && !rest.is_empty() && !name.contains('/') - && let Some((_n, cmd)) = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .find(|(command_name, _)| *command_name == name) + && let Some((_n, cmd)) = Self::built_in_slash_commands_for_input( + self.collaboration_modes_enabled, + self.connectors_enabled, + ) + .find(|(command_name, _)| *command_name == name) && cmd == SlashCommand::Review { self.textarea.set_text(""); @@ -1944,9 +1956,11 @@ impl ChatComposer { return rest_after_name.is_empty(); } - let builtin_match = - Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled) - .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); + let builtin_match = Self::built_in_slash_commands_for_input( + self.collaboration_modes_enabled, + self.connectors_enabled, + ) + .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { return true; @@ -2000,11 +2014,13 @@ impl ChatComposer { if is_editing_slash_command_name { let skills_enabled = self.skills_enabled(); let collaboration_modes_enabled = self.collaboration_modes_enabled; + let connectors_enabled = self.connectors_enabled; let mut command_popup = CommandPopup::new( self.custom_prompts.clone(), CommandPopupFlags { skills_enabled, collaboration_modes_enabled, + connectors_enabled, }, ); command_popup.on_composer_text_change(first_line.to_string()); @@ -2016,12 +2032,14 @@ impl ChatComposer { fn built_in_slash_commands_for_input( collaboration_modes_enabled: bool, + connectors_enabled: bool, ) -> impl Iterator { let allow_elevate_sandbox = windows_degraded_sandbox_active(); built_in_slash_commands() .into_iter() .filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(move |(_, cmd)| connectors_enabled || *cmd != SlashCommand::Connectors) } pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs index 000ce2e4ea..6da8c4f3a8 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -41,6 +41,7 @@ pub(crate) struct CommandPopup { pub(crate) struct CommandPopupFlags { pub(crate) skills_enabled: bool, pub(crate) collaboration_modes_enabled: bool, + pub(crate) connectors_enabled: bool, } impl CommandPopup { @@ -51,6 +52,7 @@ impl CommandPopup { .filter(|(_, cmd)| flags.skills_enabled || *cmd != SlashCommand::Skills) .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab) + .filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Connectors) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); @@ -437,6 +439,7 @@ mod tests { CommandPopupFlags { skills_enabled: false, collaboration_modes_enabled: true, + connectors_enabled: false, }, ); popup.on_composer_text_change("/collab".to_string()); diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 331cf82fd5..d5df125a47 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -186,6 +186,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_connectors_enabled(&mut self, enabled: bool) { + self.composer.set_connectors_enabled(enabled); + self.request_redraw(); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 756d244255..1eb4d8c67b 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -30,6 +30,7 @@ use std::time::Instant; 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; @@ -111,6 +112,7 @@ use tokio::task::JoinHandle; use tracing::debug; use crate::app_event::AppEvent; +use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; @@ -317,6 +319,15 @@ enum RateLimitSwitchPromptState { Shown, } +#[derive(Debug, Clone, Default)] +enum ConnectorsCacheState { + #[default] + Uninitialized, + Loading, + Ready(ConnectorsSnapshot), + Failed(String), +} + type CollaborationModeSelection = collaboration_modes::Selection; /// Maintains the per-session UI state and interaction state machines for the chat screen. @@ -380,6 +391,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 @@ -554,6 +566,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); } @@ -1507,6 +1522,7 @@ impl ChatWidget { task_complete_pending: false, agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -1536,6 +1552,9 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget + .bottom_pane + .set_connectors_enabled(widget.config.features.enabled(Feature::Connectors)); widget } @@ -1608,6 +1627,7 @@ impl ChatWidget { task_complete_pending: false, agent_turn_running: false, mcp_startup_status: None, + connectors_cache: ConnectorsCacheState::default(), interrupts: InterruptManager::new(), reasoning_buffer: String::new(), full_reasoning_buffer: String::new(), @@ -1637,6 +1657,9 @@ impl ChatWidget { widget.bottom_pane.set_collaboration_modes_enabled( widget.config.features.enabled(Feature::CollaborationModes), ); + widget + .bottom_pane + .set_connectors_enabled(widget.config.features.enabled(Feature::Connectors)); widget } @@ -1914,6 +1937,9 @@ impl ChatWidget { SlashCommand::Mcp => { self.add_mcp_output(); } + SlashCommand::Connectors => { + self.add_connectors_output(); + } SlashCommand::Rollout => { if let Some(path) = self.rollout_path() { self.add_info_message( @@ -2492,6 +2518,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(); @@ -3658,9 +3706,10 @@ impl ChatWidget { } if feature == Feature::Steer { self.bottom_pane.set_steer_enabled(enabled); - } - if feature == Feature::CollaborationModes { + } else if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); + } else if feature == Feature::Connectors { + self.bottom_pane.set_connectors_enabled(enabled); } } @@ -3706,6 +3755,10 @@ impl ChatWidget { self.config.features.enabled(Feature::CollaborationModes) } + fn connectors_enabled(&self) -> bool { + self.config.features.enabled(Feature::Connectors) + } + fn model_display_name(&self) -> &str { self.model.as_deref().unwrap_or(DEFAULT_MODEL_DISPLAY_NAME) } @@ -3797,6 +3850,114 @@ 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 connectors feature to use /apps.".to_string()), + ); + return; + } + + if matches!(self.connectors_cache, ConnectorsCacheState::Uninitialized) { + self.prefetch_connectors(); + } + let connectors_snapshot = match &self.connectors_cache { + ConnectorsCacheState::Ready(snapshot) => Some(snapshot.clone()), + ConnectorsCacheState::Failed(err) => { + self.add_to_history(history_cell::new_error_event(err.clone())); + None + } + ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { + self.add_to_history(history_cell::new_info_event( + "Apps are still loading.".to_string(), + Some("Try again in a moment.".to_string()), + )); + None + } + }; + if let Some(snapshot) = connectors_snapshot { + if snapshot.connectors.is_empty() { + self.add_info_message("No apps available.".to_string(), None); + } else { + self.open_connectors_popup(&snapshot.connectors); + } + } + self.request_redraw(); + } + + fn open_connectors_popup(&mut self, connectors: &[connectors::ConnectorInfo]) { + let mut items: Vec = Vec::with_capacity(connectors.len()); + for connector in connectors { + let connector_label = connectors::connector_display_label(connector); + let description = Self::connector_brief_description(connector); + let search_value = format!("{connector_label} {}", connector.connector_id); + let mut item = SelectionItem { + name: connector_label.clone(), + description: Some(description), + search_value: Some(search_value), + ..Default::default() + }; + if connector.is_accessible { + let insert_text = format!("{connector_label} connector"); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::InsertComposerText(insert_text.clone())); + })]; + item.actions = actions; + item.dismiss_on_select = true; + } else if let Some(install_url) = connector.install_url.clone() { + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_install_instructions_event(install_url.clone()), + ))); + })]; + item.dismiss_on_select = true; + item.selected_description = + Some("Press Enter to view the install link.".to_string()); + } else { + item.actions = vec![Box::new(|tx| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event("Install link unavailable.".to_string(), None), + ))); + })]; + item.dismiss_on_select = true; + item.selected_description = Some("Install link unavailable.".to_string()); + } + items.push(item); + } + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Apps".to_string()), + subtitle: Some("Select an app to insert it or view install instructions.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search apps".to_string()), + ..Default::default() + }); + } + + fn connector_brief_description(connector: &connectors::ConnectorInfo) -> 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::ConnectorInfo) -> Option { + connector + .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); @@ -3926,6 +4087,11 @@ impl ChatWidget { self.bottom_pane.set_composer_text(text); } + /// Insert text into the composer at the current cursor position. + pub(crate) fn insert_composer_text(&mut self, text: &str) { + self.bottom_pane.insert_str(text); + } + pub(crate) fn show_esc_backtrack_hint(&mut self) { self.bottom_pane.show_esc_backtrack_hint(); } @@ -3994,6 +4160,13 @@ 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), + }; + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index c404287af6..c23d16ebbf 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -411,6 +411,7 @@ async fn make_chatwidget_manual( task_complete_pending: false, 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/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index 5cdb3e7f2d..757f4ae2c3 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -1580,6 +1580,14 @@ pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHist PlainHistoryCell { lines } } +pub(crate) fn new_install_instructions_event(install_url: String) -> PrefixedWrappedHistoryCell { + let instructions = Line::from("Install this app in your browser, then reload Codex."); + let open_label = Line::from(vec!["Open:".dim()]); + let url_line = Line::from(vec![install_url.cyan().underlined()]); + let text = Text::from(vec![instructions, open_label, url_line]); + PrefixedWrappedHistoryCell::new(text, "• ".dim(), " ") +} + pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { // Use a hair space (U+200A) to create a subtle, near-invisible separation // before the text. VS16 is intentionally omitted to keep spacing tighter diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index 2e1e032983..05f1be2a6b 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -29,6 +29,8 @@ pub enum SlashCommand { Mention, Status, Mcp, + #[strum(serialize = "apps")] + Connectors, Logout, Quit, Exit, @@ -59,6 +61,7 @@ impl SlashCommand { SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Mcp => "list configured MCP tools", + SlashCommand::Connectors => "insert an app connector", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", SlashCommand::TestApproval => "test approval request", @@ -90,6 +93,7 @@ impl SlashCommand { | SlashCommand::Skills | SlashCommand::Status | SlashCommand::Mcp + | SlashCommand::Connectors | SlashCommand::Feedback | SlashCommand::Quit | SlashCommand::Exit => true, diff --git a/docs/config.md b/docs/config.md index 87945b25a1..a6037f851f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,6 +12,19 @@ Codex can connect to MCP servers configured in `~/.codex/config.toml`. See the c - https://developers.openai.com/codex/config-reference +## Apps (Connectors) + +Use `$` in the composer to insert a ChatGPT connector; the popover lists accessible +apps. Connected apps appear first and are labeled as connected; others are marked +as can be installed. The `/apps` command still lists apps, but it is read-only. +Inserting an app adds `` to the composer. Apps are exposed +through the built-in `codex_apps_mcp` MCP server when the connectors feature is +enabled. Only connectors inserted into the conversation are enabled as tools, and +there is no connectors-specific configuration in `config.toml`. By default, the +built-in server uses the same bearer token as other Codex backend requests. To +override it, set the `CODEX_CONNECTORS_TOKEN` environment variable to a non-empty +value. + ## Notify Codex can run a notification hook when the agent finishes a turn. See the configuration reference for the latest notification settings: