From c37560069a8c5137a3d74b0bd1b3913bf0a2c9f6 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Thu, 12 Feb 2026 16:30:52 -0800 Subject: [PATCH] [apps] Add is_enabled to app info. (#11417) - [x] Add is_enabled to app info and the response of `app/list`. - [x] Update TUI to have Enable/Disable button on the app detail page. --- .../schema/json/ServerNotification.json | 5 + .../codex_app_server_protocol.schemas.json | 5 + .../json/v2/AppListUpdatedNotification.json | 5 + .../schema/json/v2/AppsListResponse.json | 5 + .../schema/typescript/v2/AppInfo.ts | 11 +- .../app-server-protocol/src/protocol/v2.rs | 8 + codex-rs/app-server/README.md | 8 +- .../app-server/src/codex_message_processor.rs | 12 +- .../app-server/tests/suite/v2/app_list.rs | 89 ++++++ codex-rs/chatgpt/src/connectors.rs | 8 +- codex-rs/core/src/codex.rs | 122 +++++++- codex-rs/core/src/connectors.rs | 19 ++ .../src/tools/handlers/search_tool_bm25.rs | 151 +++++++++- codex-rs/protocol/src/protocol.rs | 6 + codex-rs/tui/src/app.rs | 133 ++++++++- codex-rs/tui/src/app_event.rs | 8 + codex-rs/tui/src/bottom_pane/app_link_view.rs | 200 +++++++++---- codex-rs/tui/src/bottom_pane/chat_composer.rs | 33 ++- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 115 +++++--- codex-rs/tui/src/chatwidget/skills.rs | 6 +- codex-rs/tui/src/chatwidget/tests.rs | 269 ++++++++++++++++++ 22 files changed, 1106 insertions(+), 113 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 05667704d..b4b271ead 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -193,6 +193,11 @@ "default": false, "type": "boolean" }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" + }, "logoUrl": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5820997fb..eba43fc5e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -10201,6 +10201,11 @@ "default": false, "type": "boolean" }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" + }, "logoUrl": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json index cf75f10ce..1638472c4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppListUpdatedNotification.json @@ -29,6 +29,11 @@ "default": false, "type": "boolean" }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" + }, "logoUrl": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json index d2b5cc5ef..cec3da5cc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AppsListResponse.json @@ -29,6 +29,11 @@ "default": false, "type": "boolean" }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" + }, "logoUrl": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts index 0957c0dd4..2f65c1832 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppInfo.ts @@ -5,4 +5,13 @@ /** * EXPERIMENTAL - app metadata returned by app-list APIs. */ -export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, }; +export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, +/** + * Whether this app is enabled in config.toml. + * Example: + * ```toml + * [apps.bad_app] + * enabled = false + * ``` + */ +isEnabled: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 7cff3e2de..f885a33be 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1290,6 +1290,14 @@ pub struct AppInfo { pub install_url: Option, #[serde(default)] pub is_accessible: bool, + /// Whether this app is enabled in config.toml. + /// Example: + /// ```toml + /// [apps.bad_app] + /// enabled = false + /// ``` + #[serde(default = "default_enabled")] + pub is_enabled: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 13c3fda1e..ae450fbed 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -763,7 +763,7 @@ To enable or disable a skill by path: ## Apps -Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible. +Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, whether it is currently accessible, and whether it is enabled in config. ```json { "method": "app/list", "id": 50, "params": { @@ -782,7 +782,8 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat "logoUrlDark": null, "distributionChannel": null, "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", - "isAccessible": true + "isAccessible": true, + "isEnabled": true } ], "nextCursor": null @@ -808,7 +809,8 @@ The server also emits `app/list/updated` notifications whenever either source (a "logoUrlDark": null, "distributionChannel": null, "installUrl": "https://chatgpt.com/apps/demo-app/demo-app", - "isAccessible": true + "isAccessible": true, + "isEnabled": true } ] } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f1e4b83d2..2b5f52a15 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4603,8 +4603,9 @@ impl CodexMessageProcessor { let _ = accessible_tx.send(AppListLoadResult::Accessible(result)); }); + let all_config = config.clone(); tokio::spawn(async move { - let result = connectors::list_all_connectors_with_options(&config, force_refetch) + let result = connectors::list_all_connectors_with_options(&all_config, force_refetch) .await .map_err(|err| format!("failed to list apps: {err}")); let _ = tx.send(AppListLoadResult::Directory(result)); @@ -4667,9 +4668,12 @@ impl CodexMessageProcessor { } } - let merged = Self::merge_loaded_apps( - all_connectors.as_deref(), - accessible_connectors.as_deref(), + let merged = connectors::with_app_enabled_state( + Self::merge_loaded_apps( + all_connectors.as_deref(), + accessible_connectors.as_deref(), + ), + &config, ); Self::send_app_list_updated_notification(&outgoing, merged.clone()).await; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 6fce72908..68d64e84d 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -86,6 +86,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = @@ -173,6 +174,78 @@ connectors = false Ok(()) } +#[tokio::test] +async fn list_apps_reports_is_enabled_from_config() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + is_accessible: false, + is_enabled: true, + }]; + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{server_url}" + +[features] +connectors = true + +[apps.beta] +enabled = false +"# + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { + data: response_data, + next_cursor, + } = to_response(response)?; + assert!(next_cursor.is_none()); + assert_eq!(response_data.len(), 1); + assert_eq!(response_data[0].id, "beta"); + assert!(!response_data[0].is_enabled); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> { let connectors = vec![ @@ -185,6 +258,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, AppInfo { id: "beta".to_string(), @@ -195,6 +269,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, ]; @@ -239,6 +314,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()), is_accessible: true, + is_enabled: true, }]; let first_update = read_app_list_updated_notification(&mut mcp).await?; @@ -254,6 +330,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, + is_enabled: true, }, AppInfo { id: "alpha".to_string(), @@ -264,6 +341,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, + is_enabled: true, }, ]; @@ -300,6 +378,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, AppInfo { id: "beta".to_string(), @@ -310,6 +389,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, ]; @@ -358,6 +438,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, + is_enabled: true, }, AppInfo { id: "beta".to_string(), @@ -368,6 +449,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: false, + is_enabled: true, }, ] ); @@ -382,6 +464,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, + is_enabled: true, }, AppInfo { id: "alpha".to_string(), @@ -392,6 +475,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, + is_enabled: true, }, ]; @@ -423,6 +507,7 @@ async fn list_apps_paginates_results() -> Result<()> { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, AppInfo { id: "beta".to_string(), @@ -433,6 +518,7 @@ async fn list_apps_paginates_results() -> Result<()> { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }, ]; @@ -486,6 +572,7 @@ async fn list_apps_paginates_results() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()), is_accessible: true, + is_enabled: true, }]; assert_eq!(first_page, expected_first); @@ -525,6 +612,7 @@ async fn list_apps_paginates_results() -> Result<()> { distribution_channel: None, install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), is_accessible: false, + is_enabled: true, }]; assert_eq!(second_page, expected_second); @@ -545,6 +633,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, }]; let tools = vec![connector_tool("beta", "Beta App")?]; let (server_url, server_handle) = diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 6398d13d1..ac0706c84 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -20,6 +20,7 @@ use codex_core::connectors::connector_install_url; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools; pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options; use codex_core::connectors::merge_connectors; +pub use codex_core::connectors::with_app_enabled_state; #[derive(Debug, Deserialize)] struct DirectoryListResponse { @@ -72,7 +73,10 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result> { ); let connectors = connectors_result?; let accessible = accessible_result?; - Ok(merge_connectors_with_accessible(connectors, accessible)) + Ok(with_app_enabled_state( + merge_connectors_with_accessible(connectors, accessible), + config, + )) } pub async fn list_all_connectors(config: &Config) -> anyhow::Result> { @@ -283,6 +287,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo { distribution_channel: app.distribution_channel, install_url: None, is_accessible: false, + is_enabled: true, } } @@ -341,6 +346,7 @@ mod tests { distribution_channel: None, install_url: None, is_accessible: false, + is_enabled: true, } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4405635ce..3f2642eeb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -114,6 +114,7 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::codex_thread::ThreadConfigSnapshot; use crate::compact::collect_user_messages; +use crate::config::CONFIG_TOML_FILE; use crate::config::Config; use crate::config::Constrained; use crate::config::ConstraintResult; @@ -246,6 +247,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_readiness::Readiness; use codex_utils_readiness::ReadinessFlag; @@ -1765,6 +1767,48 @@ impl Session { .clone() } + pub(crate) async fn reload_user_config_layer(&self) { + let config_toml_path = { + let state = self.state.lock().await; + state + .session_configuration + .codex_home + .join(CONFIG_TOML_FILE) + }; + + let user_config = match std::fs::read_to_string(&config_toml_path) { + Ok(contents) => match toml::from_str::(&contents) { + Ok(config) => config, + Err(err) => { + warn!("failed to parse user config while reloading layer: {err}"); + return; + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + toml::Value::Table(Default::default()) + } + Err(err) => { + warn!("failed to read user config while reloading layer: {err}"); + return; + } + }; + + let config_toml_path = match AbsolutePathBuf::try_from(config_toml_path) { + Ok(path) => path, + Err(err) => { + warn!("failed to resolve user config path while reloading layer: {err}"); + return; + } + }; + + let mut state = self.state.lock().await; + let mut config = (*state.session_configuration.original_config_do_not_use).clone(); + config.config_layer_stack = config + .config_layer_stack + .with_user_config(&config_toml_path, user_config); + state.session_configuration.original_config_do_not_use = Arc::new(config); + } + pub(crate) async fn new_default_turn_with_sub_id(&self, sub_id: String) -> Arc { let session_configuration = { let state = self.state.lock().await; @@ -3050,6 +3094,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::RefreshMcpServers { config } => { handlers::refresh_mcp_servers(&sess, config).await; } + Op::ReloadUserConfig => { + handlers::reload_user_config(&sess).await; + } Op::ListCustomPrompts => { handlers::list_custom_prompts(&sess, sub.id.clone()).await; } @@ -3474,6 +3521,10 @@ mod handlers { *guard = Some(refresh_config); } + pub async fn reload_user_config(sess: &Arc) { + sess.reload_user_config_layer().await; + } + 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; @@ -4114,7 +4165,10 @@ pub(crate) async fn run_turn( Ok(mcp_tools) => mcp_tools, Err(_) => return None, }; - connectors::accessible_connectors_from_mcp_tools(&mcp_tools) + connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn_context.config, + ) } else { Vec::new() }; @@ -4464,6 +4518,14 @@ fn filter_connectors_for_input( explicitly_enabled_connectors: &HashSet, skill_name_counts_lower: &HashMap, ) -> Vec { + let connectors = connectors + .into_iter() + .filter(|connector| connector.is_enabled) + .collect::>(); + if connectors.is_empty() { + return Vec::new(); + } + let user_messages = collect_user_messages(input); if user_messages.is_empty() && explicitly_enabled_connectors.is_empty() { return Vec::new(); @@ -4710,7 +4772,10 @@ async fn built_tools( let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| { build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1 }); - let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools); + let connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn_context.config, + ); Some(filter_connectors_for_input( connectors, input, @@ -5577,6 +5642,7 @@ mod tests { distribution_channel: None, install_url: None, is_accessible: true, + is_enabled: true, } } @@ -5662,6 +5728,42 @@ mod tests { } } + #[tokio::test] + async fn reload_user_config_layer_updates_effective_apps_config() { + let (session, _turn_context) = make_session_and_context().await; + let codex_home = session.codex_home().await; + std::fs::create_dir_all(&codex_home).expect("create codex home"); + let config_toml_path = codex_home.join(CONFIG_TOML_FILE); + std::fs::write( + &config_toml_path, + "[apps.calendar]\nenabled = false\ndisabled_reason = \"user\"\n", + ) + .expect("write user config"); + + session.reload_user_config_layer().await; + + let config = session.get_config().await; + let apps_toml = config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("apps")) + .cloned() + .expect("apps table"); + let apps = crate::config::types::AppsConfigToml::deserialize(apps_toml) + .expect("deserialize apps config"); + let app = apps + .apps + .get("calendar") + .expect("calendar app config exists"); + + assert!(!app.enabled); + assert_eq!( + app.disabled_reason, + Some(crate::config::types::AppDisabledReason::User) + ); + } + #[test] fn filter_connectors_for_input_skips_duplicate_slug_mentions() { let connectors = vec![ @@ -5699,6 +5801,22 @@ mod tests { assert_eq!(selected, Vec::new()); } + #[test] + fn filter_connectors_for_input_skips_disabled_connectors() { + let mut connector = make_connector("calendar", "Calendar"); + connector.is_enabled = false; + let input = vec![user_message("use $calendar")]; + let explicitly_enabled_connectors = HashSet::new(); + let selected = filter_connectors_for_input( + vec![connector], + &input, + &explicitly_enabled_connectors, + &HashMap::new(), + ); + + assert_eq!(selected, Vec::new()); + } + #[test] fn collect_explicit_app_ids_from_skill_items_includes_linked_mentions() { let connectors = vec![make_connector("calendar", "Calendar")]; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 1769446b6..72c5d113e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -9,6 +9,7 @@ use std::time::Instant; use async_channel::unbounded; pub use codex_app_server_protocol::AppInfo; use codex_protocol::protocol::SandboxPolicy; +use serde::Deserialize; use tokio_util::sync::CancellationToken; use tracing::warn; @@ -16,6 +17,7 @@ use crate::AuthManager; use crate::CodexAuth; use crate::SandboxState; use crate::config::Config; +use crate::config::types::AppsConfigToml; use crate::features::Feature; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::auth::compute_auth_statuses; @@ -265,6 +267,22 @@ pub fn merge_connectors( merged } +pub fn with_app_enabled_state(mut connectors: Vec, config: &Config) -> Vec { + let apps = read_apps_config(config).map(|apps_config| apps_config.apps); + for connector in &mut connectors { + if let Some(app) = apps.as_ref().and_then(|apps| apps.get(&connector.id)) { + connector.is_enabled = app.enabled; + } + } + connectors +} + +fn read_apps_config(config: &Config) -> Option { + let effective_config = config.config_layer_stack.effective_config(); + let apps_config = effective_config.as_table()?.get("apps")?.clone(); + AppsConfigToml::deserialize(apps_config).ok() +} + fn collect_accessible_connectors(tools: I) -> Vec where I: IntoIterator)>, @@ -291,6 +309,7 @@ where distribution_channel: None, install_url: Some(connector_install_url(&connector_name, &connector_id)), is_accessible: true, + is_enabled: true, }) .collect(); accessible.sort_by(|left, right| { diff --git a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs index 1da8a3d03..54dd5c3dd 100644 --- a/codex-rs/core/src/tools/handlers/search_tool_bm25.rs +++ b/codex-rs/core/src/tools/handlers/search_tool_bm25.rs @@ -2,11 +2,17 @@ use async_trait::async_trait; use bm25::Document; use bm25::Language; use bm25::SearchEngineBuilder; +use codex_app_server_protocol::AppInfo; use codex_protocol::models::FunctionCallOutputBody; use serde::Deserialize; use serde_json::json; +use std::collections::HashMap; +use std::collections::HashSet; +use crate::connectors; +use crate::features::Feature; use crate::function_tool::FunctionCallError; +use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; @@ -76,7 +82,10 @@ impl ToolHandler for SearchToolBm25Handler { async fn handle(&self, invocation: ToolInvocation) -> Result { let ToolInvocation { - payload, session, .. + payload, + session, + turn, + .. } = invocation; let arguments = match payload { @@ -111,6 +120,15 @@ impl ToolHandler for SearchToolBm25Handler { .await .list_all_tools() .await; + let mcp_tools = if turn.config.features.enabled(Feature::Apps) { + let connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + filter_codex_apps_mcp_tools(mcp_tools, &connectors) + } else { + mcp_tools + }; let mut entries: Vec = mcp_tools .into_iter() @@ -178,6 +196,28 @@ impl ToolHandler for SearchToolBm25Handler { } } +fn filter_codex_apps_mcp_tools( + mut mcp_tools: HashMap, + connectors: &[AppInfo], +) -> HashMap { + let enabled_connectors: HashSet<&str> = connectors + .iter() + .filter(|connector| connector.is_enabled) + .map(|connector| connector.id.as_str()) + .collect(); + + mcp_tools.retain(|_, tool| { + if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { + return true; + } + + tool.connector_id + .as_deref() + .is_some_and(|connector_id| enabled_connectors.contains(connector_id)) + }); + mcp_tools +} + fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String { let mut parts = vec![ name.to_string(), @@ -215,3 +255,112 @@ fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> Stri parts.join(" ") } + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::AppInfo; + use pretty_assertions::assert_eq; + use rmcp::model::JsonObject; + use rmcp::model::Tool; + use std::sync::Arc; + + fn make_connector(id: &str, enabled: bool) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: None, + is_accessible: true, + is_enabled: enabled, + } + } + + fn make_tool( + qualified_name: &str, + server_name: &str, + tool_name: &str, + connector_id: Option<&str>, + ) -> (String, ToolInfo) { + ( + qualified_name.to_string(), + ToolInfo { + server_name: server_name.to_string(), + tool_name: tool_name.to_string(), + tool: Tool { + name: tool_name.to_string().into(), + title: None, + description: Some(format!("Test tool: {tool_name}").into()), + input_schema: Arc::new(JsonObject::default()), + output_schema: None, + annotations: None, + execution: None, + icons: None, + meta: None, + }, + connector_id: connector_id.map(str::to_string), + connector_name: connector_id.map(str::to_string), + }, + ) + } + + #[test] + fn filter_codex_apps_mcp_tools_keeps_non_apps_and_enabled_apps() { + let mcp_tools = HashMap::from([ + make_tool( + "mcp__codex_apps__calendar_create_event", + CODEX_APPS_MCP_SERVER_NAME, + "calendar_create_event", + Some("calendar"), + ), + make_tool( + "mcp__codex_apps__drive_search", + CODEX_APPS_MCP_SERVER_NAME, + "drive_search", + Some("drive"), + ), + make_tool("mcp__rmcp__echo", "rmcp", "echo", None), + ]); + let connectors = vec![ + make_connector("calendar", false), + make_connector("drive", true), + ]; + + let mut filtered: Vec = filter_codex_apps_mcp_tools(mcp_tools, &connectors) + .into_keys() + .collect(); + filtered.sort(); + + assert_eq!( + filtered, + vec![ + "mcp__codex_apps__drive_search".to_string(), + "mcp__rmcp__echo".to_string(), + ] + ); + } + + #[test] + fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() { + let mcp_tools = HashMap::from([ + make_tool( + "mcp__codex_apps__unknown", + CODEX_APPS_MCP_SERVER_NAME, + "unknown", + None, + ), + make_tool("mcp__rmcp__echo", "rmcp", "echo", None), + ]); + + let mut filtered: Vec = + filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)]) + .into_keys() + .collect(); + filtered.sort(); + + assert_eq!(filtered, vec!["mcp__rmcp__echo".to_string()]); + } +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 745068b16..82499c670 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -260,6 +260,12 @@ pub enum Op { /// Request MCP servers to reinitialize and refresh cached tool lists. RefreshMcpServers { config: McpServerRefreshConfig }, + /// Reload user config layer overrides for the active session. + /// + /// This updates runtime config-derived behavior (for example app + /// enable/disable state) without restarting the thread. + ReloadUserConfig, + /// Request the list of available custom prompts. ListCustomPrompts, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e30d490bb..778eedd8d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -626,6 +626,13 @@ impl App { .wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}")) } + async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> { + let mut config = self.rebuild_config_for_cwd(self.config.cwd.clone()).await?; + self.apply_runtime_policy_overrides(&mut config); + self.config = config; + Ok(()) + } + fn apply_runtime_policy_overrides(&mut self, config: &mut Config) { if let Some(policy) = self.runtime_approval_policy_override.as_ref() && let Err(err) = config.permissions.approval_policy.set(*policy) @@ -1598,19 +1605,24 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::OpenAppLink { + app_id, title, description, instructions, url, is_installed, + is_enabled, } => { - self.chat_widget.open_app_link_view( - title, - description, - instructions, - url, - is_installed, - ); + self.chat_widget + .open_app_link_view(crate::bottom_pane::AppLinkViewParams { + app_id, + title, + description, + instructions, + url, + is_installed, + is_enabled, + }); } AppEvent::OpenUrlInBrowser { url } => { self.open_url_in_browser(url); @@ -2286,6 +2298,12 @@ impl App { { Ok(()) => { self.chat_widget.update_skill_enabled(path.clone(), enabled); + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after skill toggle" + ); + } } Err(err) => { let path_display = path.display(); @@ -2295,6 +2313,55 @@ impl App { } } } + AppEvent::SetAppEnabled { id, enabled } => { + let edits = if enabled { + vec![ + ConfigEdit::ClearPath { + segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()], + }, + ConfigEdit::ClearPath { + segments: vec![ + "apps".to_string(), + id.clone(), + "disabled_reason".to_string(), + ], + }, + ] + } else { + vec![ + ConfigEdit::SetPath { + segments: vec!["apps".to_string(), id.clone(), "enabled".to_string()], + value: false.into(), + }, + ConfigEdit::SetPath { + segments: vec![ + "apps".to_string(), + id.clone(), + "disabled_reason".to_string(), + ], + value: "user".into(), + }, + ] + }; + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(edits) + .apply() + .await + { + Ok(()) => { + self.chat_widget.update_connector_enabled(&id, enabled); + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!(error = %err, "failed to refresh config after app toggle"); + } + self.chat_widget.submit_op(Op::ReloadUserConfig); + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to update app config for {id}: {err}" + )); + } + } + } AppEvent::OpenPermissionsPopup => { self.chat_widget.open_permissions_popup(); } @@ -2929,6 +2996,19 @@ mod tests { ) } + fn app_enabled_in_effective_config(config: &Config, app_id: &str) -> Option { + config + .config_layer_stack + .effective_config() + .as_table() + .and_then(|table| table.get("apps")) + .and_then(TomlValue::as_table) + .and_then(|apps| apps.get(app_id)) + .and_then(TomlValue::as_table) + .and_then(|app| app.get("enabled")) + .and_then(TomlValue::as_bool) + } + fn all_model_presets() -> Vec { codex_core::test_support::all_model_presets().clone() } @@ -3108,6 +3188,45 @@ mod tests { ); } + #[tokio::test] + async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf(); + let app_id = "connector_1".to_string(); + + assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); + + ConfigEditsBuilder::new(&app.config.codex_home) + .with_edits([ + ConfigEdit::SetPath { + segments: vec!["apps".to_string(), app_id.clone(), "enabled".to_string()], + value: false.into(), + }, + ConfigEdit::SetPath { + segments: vec![ + "apps".to_string(), + app_id.clone(), + "disabled_reason".to_string(), + ], + value: "user".into(), + }, + ]) + .apply() + .await + .expect("persist app toggle"); + + assert_eq!(app_enabled_in_effective_config(&app.config, &app_id), None); + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!( + app_enabled_in_effective_config(&app.config, &app_id), + Some(false) + ); + Ok(()) + } + #[tokio::test] async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 67db6d7c8..c04665eb2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -107,11 +107,13 @@ pub(crate) enum AppEvent { /// Open the app link view in the bottom pane. OpenAppLink { + app_id: String, title: String, description: Option, instructions: String, url: String, is_installed: bool, + is_enabled: bool, }, /// Open the provided URL in the user's browser. @@ -297,6 +299,12 @@ pub(crate) enum AppEvent { enabled: bool, }, + /// Enable or disable an app by connector ID. + SetAppEnabled { + id: String, + enabled: bool, + }, + /// Notify that the manage skills popup was closed. ManageSkillsClosed, diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 05ef32bfa..698825d6f 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -32,12 +32,24 @@ enum AppLinkScreen { InstallConfirmation, } +pub(crate) struct AppLinkViewParams { + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) description: Option, + pub(crate) instructions: String, + pub(crate) url: String, + pub(crate) is_installed: bool, + pub(crate) is_enabled: bool, +} + pub(crate) struct AppLinkView { + app_id: String, title: String, description: Option, instructions: String, url: String, is_installed: bool, + is_enabled: bool, app_event_tx: AppEventSender, screen: AppLinkScreen, selected_action: usize, @@ -45,20 +57,24 @@ pub(crate) struct AppLinkView { } impl AppLinkView { - pub(crate) fn new( - title: String, - description: Option, - instructions: String, - url: String, - is_installed: bool, - app_event_tx: AppEventSender, - ) -> Self { - Self { + pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self { + let AppLinkViewParams { + app_id, title, description, instructions, url, is_installed, + is_enabled, + } = params; + Self { + app_id, + title, + description, + instructions, + url, + is_installed, + is_enabled, app_event_tx, screen: AppLinkScreen::Link, selected_action: 0, @@ -66,16 +82,24 @@ impl AppLinkView { } } - fn action_labels(&self) -> [&'static str; 2] { + fn action_labels(&self) -> Vec<&'static str> { match self.screen { AppLinkScreen::Link => { if self.is_installed { - ["Manage on ChatGPT", "Back"] + vec![ + "Manage on ChatGPT", + if self.is_enabled { + "Disable app" + } else { + "Enable app" + }, + "Back", + ] } else { - ["Install on ChatGPT", "Back"] + vec!["Install on ChatGPT", "Back"] } } - AppLinkScreen::InstallConfirmation => ["I already Installed it", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I already Installed it", "Back"], } } @@ -87,42 +111,47 @@ impl AppLinkView { self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1); } - fn handle_primary_action(&mut self) { - match self.screen { - AppLinkScreen::Link => { - self.app_event_tx.send(AppEvent::OpenUrlInBrowser { - url: self.url.clone(), - }); - if !self.is_installed { - self.screen = AppLinkScreen::InstallConfirmation; - self.selected_action = 0; - } - } - AppLinkScreen::InstallConfirmation => { - self.app_event_tx.send(AppEvent::RefreshConnectors { - force_refetch: true, - }); - self.complete = true; - } + fn open_chatgpt_link(&mut self) { + self.app_event_tx.send(AppEvent::OpenUrlInBrowser { + url: self.url.clone(), + }); + if !self.is_installed { + self.screen = AppLinkScreen::InstallConfirmation; + self.selected_action = 0; } } - fn handle_secondary_action(&mut self) { - match self.screen { - AppLinkScreen::Link => { - self.complete = true; - } - AppLinkScreen::InstallConfirmation => { - self.screen = AppLinkScreen::Link; - self.selected_action = 0; - } - } + fn refresh_connectors_and_close(&mut self) { + self.app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + self.complete = true; + } + + fn back_to_link_screen(&mut self) { + self.screen = AppLinkScreen::Link; + self.selected_action = 0; + } + + fn toggle_enabled(&mut self) { + self.is_enabled = !self.is_enabled; + self.app_event_tx.send(AppEvent::SetAppEnabled { + id: self.app_id.clone(), + enabled: self.is_enabled, + }); } fn activate_selected_action(&mut self) { - match self.selected_action { - 0 => self.handle_primary_action(), - _ => self.handle_secondary_action(), + match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_chatgpt_link(), + 1 if self.is_installed => self.toggle_enabled(), + _ => self.complete = true, + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.refresh_connectors_and_close(), + _ => self.back_to_link_screen(), + }, } } @@ -308,20 +337,19 @@ impl BottomPaneView for AppLinkView { .. } => self.move_selection_next(), KeyEvent { - code: KeyCode::Char('1'), + code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, .. } => { - self.selected_action = 0; - self.activate_selected_action(); - } - KeyEvent { - code: KeyCode::Char('2'), - modifiers: KeyModifiers::NONE, - .. - } => { - self.selected_action = 1; - self.activate_selected_action(); + if let Some(index) = c + .to_digit(10) + .and_then(|digit| digit.checked_sub(1)) + .map(|index| index as usize) + && index < self.action_labels().len() + { + self.selected_action = index; + self.activate_selected_action(); + } } KeyEvent { code: KeyCode::Enter, @@ -402,3 +430,67 @@ impl crate::render::renderable::Renderable for AppLinkView { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn installed_app_has_toggle_action() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + }, + tx, + ); + + assert_eq!( + view.action_labels(), + vec!["Manage on ChatGPT", "Disable app", "Back"] + ); + } + + #[test] + fn toggle_action_sends_set_app_enabled_and_updates_label() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: "https://example.test/notion".to_string(), + is_installed: true, + is_enabled: true, + }, + tx, + ); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + + match rx.try_recv() { + Ok(AppEvent::SetAppEnabled { id, enabled }) => { + assert_eq!(id, "connector_1"); + assert!(!enabled); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + + assert_eq!( + view.action_labels(), + vec!["Manage on ChatGPT", "Enable app", "Back"] + ); + } +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 3ceeb728c..5ef643f77 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3099,7 +3099,7 @@ impl ChatComposer { && let Some(snapshot) = self.connectors_snapshot.as_ref() { for connector in &snapshot.connectors { - if !connector.is_accessible { + if !connector.is_accessible || !connector.is_enabled { continue; } let display_name = connectors::connector_display_label(connector); @@ -4342,6 +4342,7 @@ mod tests { distribution_channel: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, + is_enabled: true, }]; composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); @@ -4355,6 +4356,36 @@ mod tests { assert_eq!(mention.path, Some("app://connector_1".to_string())); } + #[test] + fn set_connector_mentions_excludes_disabled_apps_from_mention_popup() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_connectors_enabled(true); + composer.set_text_content("$".to_string(), Vec::new(), Vec::new()); + + let connectors = vec![AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: false, + }]; + composer.set_connector_mentions(Some(ConnectorsSnapshot { connectors })); + + assert!(matches!(composer.active_popup, ActivePopup::None)); + } + #[test] fn shortcut_overlay_persists_while_task_running() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 32029996a..5ad79df69 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -44,6 +44,7 @@ mod multi_select_picker; mod request_user_input; mod status_line_setup; pub(crate) use app_link_view::AppLinkView; +pub(crate) use app_link_view::AppLinkViewParams; pub(crate) use approval_overlay::ApprovalOverlay; pub(crate) use approval_overlay::ApprovalRequest; pub(crate) use request_user_input::RequestUserInputOverlay; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5dc8605f5..81c9faed6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1141,22 +1141,8 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn open_app_link_view( - &mut self, - title: String, - description: Option, - instructions: String, - url: String, - is_installed: bool, - ) { - let view = crate::bottom_pane::AppLinkView::new( - title, - description, - instructions, - url, - is_installed, - self.app_event_tx.clone(), - ); + pub(crate) fn open_app_link_view(&mut self, params: crate::bottom_pane::AppLinkViewParams) { + let view = crate::bottom_pane::AppLinkView::new(params, self.app_event_tx.clone()); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); } @@ -3781,7 +3767,7 @@ impl ChatWidget { if !selected_app_ids.insert(app_id.to_string()) { continue; } - if let Some(app) = apps.iter().find(|app| app.id == app_id) { + if let Some(app) = apps.iter().find(|app| app.id == app_id && app.is_enabled) { items.push(UserInput::Mention { name: app.name.clone(), path: binding.path.clone(), @@ -6414,6 +6400,7 @@ impl ChatWidget { let connector_title = connector_label.clone(); let link_description = Self::connector_description(connector); let description = Self::connector_brief_description(connector); + let status_label = Self::connector_status_label(connector); let search_value = format!("{connector_label} {}", connector.id); let mut item = SelectionItem { name: connector_label, @@ -6422,42 +6409,47 @@ impl ChatWidget { ..Default::default() }; let is_installed = connector.is_accessible; - let (selected_label, missing_label, instructions) = if connector.is_accessible { - ( - "Press Enter to view the app link.", - "App link unavailable.", - "Manage this app in your browser.", + let selected_label = if is_installed { + format!( + "{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app." ) } else { - ( - "Press Enter to view the install link.", - "Install link unavailable.", - "Install this app in your browser, then reload Codex.", - ) + format!("{status_label}. Press Enter to open the app page to install this app.") + }; + let missing_label = format!("{status_label}. App link unavailable."); + let instructions = if connector.is_accessible { + "Manage this app in your browser." + } else { + "Install this app in your browser, then reload Codex." }; if let Some(install_url) = connector.install_url.clone() { + let app_id = connector.id.clone(); + let is_enabled = connector.is_enabled; let title = connector_title.clone(); let instructions = instructions.to_string(); let description = link_description.clone(); item.actions = vec![Box::new(move |tx| { tx.send(AppEvent::OpenAppLink { + app_id: app_id.clone(), title: title.clone(), description: description.clone(), instructions: instructions.clone(), url: install_url.clone(), is_installed, + is_enabled, }); })]; item.dismiss_on_select = true; - item.selected_description = Some(selected_label.to_string()); + item.selected_description = Some(selected_label); } else { + let missing_label_for_action = missing_label.clone(); item.actions = vec![Box::new(move |tx| { tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(missing_label.to_string(), None), + history_cell::new_info_event(missing_label_for_action.clone(), None), ))); })]; item.dismiss_on_select = true; - item.selected_description = Some(missing_label.to_string()); + item.selected_description = Some(missing_label); } items.push(item); } @@ -6490,17 +6482,25 @@ impl ChatWidget { } fn connector_brief_description(connector: &connectors::AppInfo) -> String { - let status_label = if connector.is_accessible { - "Connected" - } else { - "Can be installed" - }; + let status_label = Self::connector_status_label(connector); match Self::connector_description(connector) { Some(description) => format!("{status_label} · {description}"), None => status_label.to_string(), } } + fn connector_status_label(connector: &connectors::AppInfo) -> &'static str { + if connector.is_accessible { + if connector.is_enabled { + "Installed" + } else { + "Installed · Disabled" + } + } else { + "Can be installed" + } + } + fn connector_description(connector: &connectors::AppInfo) -> Option { connector .description @@ -6727,7 +6727,27 @@ impl ChatWidget { } match result { - Ok(snapshot) => { + Ok(mut snapshot) => { + if !is_final { + snapshot.connectors = connectors::merge_connectors_with_accessible( + Vec::new(), + snapshot.connectors, + ); + } + snapshot.connectors = + connectors::with_app_enabled_state(snapshot.connectors, &self.config); + if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors_cache { + let enabled_by_id: HashMap<&str, bool> = existing_snapshot + .connectors + .iter() + .map(|connector| (connector.id.as_str(), connector.is_enabled)) + .collect(); + for connector in &mut snapshot.connectors { + if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) { + connector.is_enabled = *is_enabled; + } + } + } self.refresh_connectors_popup_if_open(&snapshot.connectors); if is_final || !matches!(self.connectors_cache, ConnectorsCacheState::Ready(_)) { self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone()); @@ -6746,6 +6766,29 @@ impl ChatWidget { } } + pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { + let ConnectorsCacheState::Ready(mut snapshot) = self.connectors_cache.clone() else { + return; + }; + + let mut changed = false; + for connector in &mut snapshot.connectors { + if connector.id == connector_id { + changed = connector.is_enabled != enabled; + connector.is_enabled = enabled; + break; + } + } + + if !changed { + return; + } + + self.refresh_connectors_popup_if_open(&snapshot.connectors); + self.connectors_cache = ConnectorsCacheState::Ready(snapshot.clone()); + self.bottom_pane.set_connectors_snapshot(Some(snapshot)); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); diff --git a/codex-rs/tui/src/chatwidget/skills.rs b/codex-rs/tui/src/chatwidget/skills.rs index ba8dab416..6ec4aefa6 100644 --- a/codex-rs/tui/src/chatwidget/skills.rs +++ b/codex-rs/tui/src/chatwidget/skills.rs @@ -267,12 +267,12 @@ pub(crate) fn find_app_mentions( } let mut slug_counts: HashMap = HashMap::new(); - for app in apps { + for app in apps.iter().filter(|app| app.is_enabled) { let slug = connector_mention_slug(app); *slug_counts.entry(slug).or_insert(0) += 1; } - for app in apps { + for app in apps.iter().filter(|app| app.is_enabled) { let slug = connector_mention_slug(app); let slug_count = slug_counts.get(&slug).copied().unwrap_or(0); if mentions.names.contains(&slug) @@ -285,7 +285,7 @@ pub(crate) fn find_app_mentions( } apps.iter() - .filter(|app| selected_ids.contains(&app.id)) + .filter(|app| app.is_enabled && selected_ids.contains(&app.id)) .cloned() .collect() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 82b22790f..1ff7fc14c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3878,6 +3878,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { distribution_channel: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, + is_enabled: true, }], }), false, @@ -3893,6 +3894,10 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { before.contains("Installed 1 of 1 available apps."), "expected initial apps popup snapshot, got:\n{before}" ); + assert!( + before.contains("Installed. Press Enter to open the app page"), + "expected selected app description to explain the app page action, got:\n{before}" + ); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { @@ -3906,6 +3911,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { distribution_channel: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, + is_enabled: true, }, codex_chatgpt::connectors::AppInfo { id: "connector_2".to_string(), @@ -3916,6 +3922,7 @@ async fn apps_popup_refreshes_when_connectors_snapshot_updates() { distribution_channel: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: true, + is_enabled: true, }, ], }), @@ -3949,6 +3956,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { distribution_channel: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, + is_enabled: true, }, codex_chatgpt::connectors::AppInfo { id: "connector_2".to_string(), @@ -3959,6 +3967,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { distribution_channel: None, install_url: Some("https://example.test/linear".to_string()), is_accessible: false, + is_enabled: true, }, ]; chat.on_connectors_loaded( @@ -3979,6 +3988,7 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { distribution_channel: None, install_url: Some("https://example.test/notion".to_string()), is_accessible: true, + is_enabled: true, }], }), false, @@ -3998,6 +4008,265 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { ); } +#[tokio::test] +async fn apps_partial_refresh_uses_same_filtering_as_full_refresh() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.features.enable(Feature::Apps); + chat.bottom_pane.set_connectors_enabled(true); + + let full_connectors = vec![ + codex_chatgpt::connectors::AppInfo { + id: "unit_test_connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + }, + codex_chatgpt::connectors::AppInfo { + id: "unit_test_connector_2".to_string(), + name: "Linear".to_string(), + description: Some("Project tracking".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/linear".to_string()), + is_accessible: false, + is_enabled: true, + }, + ]; + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: full_connectors.clone(), + }), + true, + ); + chat.add_connectors_output(); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![ + codex_chatgpt::connectors::AppInfo { + id: "unit_test_connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + }, + codex_chatgpt::connectors::AppInfo { + id: "connector_openai_hidden".to_string(), + name: "Hidden OpenAI".to_string(), + description: Some("Should be filtered".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/hidden-openai".to_string()), + is_accessible: true, + is_enabled: true, + }, + ], + }), + false, + ); + + assert_matches!( + &chat.connectors_cache, + ConnectorsCacheState::Ready(snapshot) if snapshot.connectors == full_connectors + ); + + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Installed 1 of 1 available apps."), + "expected partial refresh popup to use filtered connectors, got:\n{popup}" + ); + assert!( + !popup.contains("Hidden OpenAI"), + "expected disallowed connector to be filtered from partial refresh popup, got:\n{popup}" + ); +} + +#[tokio::test] +async fn apps_popup_shows_disabled_status_for_installed_but_disabled_apps() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.features.enable(Feature::Apps); + chat.bottom_pane.set_connectors_enabled(true); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![codex_chatgpt::connectors::AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: false, + }], + }), + true, + ); + + chat.add_connectors_output(); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Installed · Disabled. Press Enter to open the app page"), + "expected selected app description to include disabled status, got:\n{popup}" + ); + assert!( + popup.contains("enable/disable this app."), + "expected selected app description to mention enable/disable action, got:\n{popup}" + ); +} + +#[tokio::test] +async fn apps_initial_load_applies_enabled_state_from_config() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.features.enable(Feature::Apps); + chat.bottom_pane.set_connectors_enabled(true); + + let temp = tempdir().expect("tempdir"); + let config_toml_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let user_config = toml::from_str::( + "[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n", + ) + .expect("apps config"); + chat.config.config_layer_stack = chat + .config + .config_layer_stack + .with_user_config(&config_toml_path, user_config); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![codex_chatgpt::connectors::AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + }], + }), + true, + ); + + assert_matches!( + &chat.connectors_cache, + ConnectorsCacheState::Ready(snapshot) + if snapshot + .connectors + .iter() + .find(|connector| connector.id == "connector_1") + .is_some_and(|connector| !connector.is_enabled) + ); +} + +#[tokio::test] +async fn apps_refresh_preserves_toggled_enabled_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.features.enable(Feature::Apps); + chat.bottom_pane.set_connectors_enabled(true); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![codex_chatgpt::connectors::AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + }], + }), + true, + ); + chat.update_connector_enabled("connector_1", false); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![codex_chatgpt::connectors::AppInfo { + id: "connector_1".to_string(), + name: "Notion".to_string(), + description: Some("Workspace docs".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/notion".to_string()), + is_accessible: true, + is_enabled: true, + }], + }), + true, + ); + + assert_matches!( + &chat.connectors_cache, + ConnectorsCacheState::Ready(snapshot) + if snapshot + .connectors + .iter() + .find(|connector| connector.id == "connector_1") + .is_some_and(|connector| !connector.is_enabled) + ); + + chat.add_connectors_output(); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Installed · Disabled. Press Enter to open the app page"), + "expected disabled status to persist after reload, got:\n{popup}" + ); +} + +#[tokio::test] +async fn apps_popup_for_not_installed_app_uses_install_only_selected_description() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.config.features.enable(Feature::Apps); + chat.bottom_pane.set_connectors_enabled(true); + + chat.on_connectors_loaded( + Ok(ConnectorsSnapshot { + connectors: vec![codex_chatgpt::connectors::AppInfo { + id: "connector_2".to_string(), + name: "Linear".to_string(), + description: Some("Project tracking".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + install_url: Some("https://example.test/linear".to_string()), + is_accessible: false, + is_enabled: true, + }], + }), + true, + ); + + chat.add_connectors_output(); + let popup = render_bottom_popup(&chat, 80); + assert!( + popup.contains("Can be installed. Press Enter to open the app page to install"), + "expected selected app description to be install-only for not-installed apps, got:\n{popup}" + ); + assert!( + !popup.contains("enable/disable this app."), + "did not expect enable/disable text for not-installed apps, got:\n{popup}" + ); +} + #[tokio::test] async fn experimental_features_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;