diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d30b747ebc..4d74b4d4c6 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1596,18 +1596,22 @@ 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( + app_id, title, description, instructions, url, is_installed, + is_enabled, ); } AppEvent::OpenUrlInBrowser { url } => { @@ -2214,6 +2218,51 @@ 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); + } + 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(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0b85fafbb7..79d1995197 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. @@ -278,6 +280,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 05ef32bfa8..db2af07400 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -33,11 +33,13 @@ enum AppLinkScreen { } 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, @@ -46,19 +48,23 @@ pub(crate) struct AppLinkView { impl AppLinkView { pub(crate) fn new( + app_id: String, title: String, description: Option, instructions: String, url: String, is_installed: bool, + is_enabled: bool, app_event_tx: AppEventSender, ) -> Self { Self { + app_id, title, description, instructions, url, is_installed, + is_enabled, app_event_tx, screen: AppLinkScreen::Link, selected_action: 0, @@ -66,16 +72,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 +101,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 +327,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 +420,63 @@ 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( + "connector_1".to_string(), + "Notion".to_string(), + None, + "Manage app".to_string(), + "https://example.test/notion".to_string(), + true, + 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( + "connector_1".to_string(), + "Notion".to_string(), + None, + "Manage app".to_string(), + "https://example.test/notion".to_string(), + true, + 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 38376a5278..fba7aa0473 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/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9920e8d1b5..bacfbca633 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1149,18 +1149,22 @@ impl ChatWidget { pub(crate) fn open_app_link_view( &mut self, + app_id: String, title: String, description: Option, instructions: String, url: String, is_installed: bool, + is_enabled: bool, ) { let view = crate::bottom_pane::AppLinkView::new( + app_id, title, description, instructions, url, is_installed, + is_enabled, self.app_event_tx.clone(), ); self.bottom_pane.show_view(Box::new(view)); @@ -3747,7 +3751,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(), @@ -6435,6 +6439,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, @@ -6443,42 +6448,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); } @@ -6511,17 +6521,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 @@ -6767,6 +6785,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 ba8dab416d..6ec4aefa6d 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 280db452f4..33c4c1ba21 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3731,6 +3731,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, @@ -3746,6 +3747,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 { @@ -3759,6 +3764,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(), @@ -3769,6 +3775,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, }, ], }), @@ -3802,6 +3809,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(), @@ -3812,6 +3820,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( @@ -3832,6 +3841,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, @@ -3851,6 +3861,76 @@ async fn apps_refresh_failure_keeps_existing_full_snapshot() { ); } +#[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_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;