From 38b638d89d89bdc2a6bc43d0bc2d084254df29b9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 24 Mar 2026 17:40:26 -0700 Subject: [PATCH] Add legal link to TUI /plugin details (#15692) - Adds language and "[learn more](https://help.openai.com/en/articles/11487775-apps-in-chatgpt)" link to plugin details pages. - Message is hidden when plugin is installed image --- codex-rs/tui/src/chatwidget/plugins.rs | 56 ++++++++++++- ...ests__plugin_detail_popup_installable.snap | 5 +- ..._tests__plugin_detail_popup_installed.snap | 15 ++++ codex-rs/tui/src/chatwidget/tests.rs | 83 ++++++++++++++++++- codex-rs/tui/src/onboarding/mod.rs | 1 + .../tui_app_server/src/chatwidget/plugins.rs | 56 ++++++++++++- ...ests__plugin_detail_popup_installable.snap | 5 +- ..._tests__plugin_detail_popup_installed.snap | 15 ++++ .../tui_app_server/src/chatwidget/tests.rs | 83 ++++++++++++++++++- codex-rs/tui_app_server/src/onboarding/mod.rs | 1 + 10 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installed.snap diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index e44081f76..ce08cc879 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -8,6 +8,7 @@ use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; +use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; @@ -24,10 +25,12 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); @@ -93,6 +96,29 @@ impl Renderable for DelayedLoadingHeader { } } +const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; + +struct PluginDisclosureLine { + line: Line<'static>, +} + +impl Renderable for PluginDisclosureLine { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .render(area, buf); + mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(u16::MAX) + } +} + #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { #[default] @@ -812,13 +838,37 @@ impl ChatWidget { ) -> SelectionViewParams { let marketplace_label = plugin.marketplace_name.clone(); let display_name = plugin_display_name(&plugin.summary); - let status_label = plugin_status_label(&plugin.summary); + let detail_status_label = if plugin.summary.installed { + if plugin.summary.enabled { + "Installed" + } else { + "Installed · Disabled" + } + } else { + match plugin.summary.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + }; let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( - format!("{display_name} · {marketplace_label}").bold(), + format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), )); - header.push(Line::from(status_label.dim())); + if !plugin.summary.installed { + header.push(PluginDisclosureLine { + line: Line::from(vec![ + "Data shared with this app is subject to the app's ".into(), + "terms of service".bold(), + " and ".into(), + "privacy policy".bold(), + ". ".into(), + "Learn more".cyan().underlined(), + ".".into(), + ]), + }); + } if let Some(description) = plugin_detail_description(plugin) { header.push(Line::from(description.dim())); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index 0388e3d16..b9e5683c4 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -3,8 +3,9 @@ source: tui/src/chatwidget/tests.rs expression: popup --- Plugins - Figma · ChatGPT Marketplace - Available + Figma · Can be installed · ChatGPT Marketplace + Data shared with this app is subject to the app's terms of service and privacy policy. Learn + more. Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap new file mode 100644 index 000000000..71ae46d78 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · Installed · ChatGPT Marketplace + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Uninstall plugin Remove this plugin now. + Skills design-review, extract-copy + Apps Figma, Slack + MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fad59d5cb..e1314e27f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -7083,6 +7083,40 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn strip_osc8_for_snapshot(text: &str) -> String { + // Snapshots should assert the visible popup text, not terminal hyperlink escapes. + let bytes = text.as_bytes(); + let mut stripped = String::with_capacity(text.len()); + let mut i = 0; + + while i < bytes.len() { + if bytes[i..].starts_with(b"\x1B]8;;") { + i += 5; + while i < bytes.len() { + if bytes[i] == b'\x07' { + i += 1; + break; + } + if i + 1 < bytes.len() && bytes[i] == b'\x1B' && bytes[i + 1] == b'\\' { + i += 2; + break; + } + i += 1; + } + continue; + } + + let ch = text[i..] + .chars() + .next() + .expect("slice should always contain a char"); + stripped.push(ch); + i += ch.len_utf8(); + } + + stripped +} + fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from( std::env::temp_dir() @@ -7338,7 +7372,54 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa ); let popup = render_bottom_popup(&chat, 100); - assert_snapshot!("plugin_detail_popup_installable", popup); + assert_snapshot!( + "plugin_detail_popup_installable", + strip_osc8_for_snapshot(&popup) + ); +} + +#[tokio::test] +async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + true, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert!( + !popup.contains("Data shared with this app is subject to the app's"), + "expected installed plugin details to hide the disclosure line, got:\n{popup}" + ); + assert_snapshot!( + "plugin_detail_popup_installed", + strip_osc8_for_snapshot(&popup) + ); } #[tokio::test] diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index d4cfd6d1f..8a4dc760f 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,5 +1,6 @@ mod auth; pub mod onboarding_screen; mod trust_directory; +pub(crate) use auth::mark_url_hyperlink; pub use trust_directory::TrustDirectorySelection; mod welcome; diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index e44081f76..ce08cc879 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -8,6 +8,7 @@ use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; +use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; @@ -24,10 +25,12 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); @@ -93,6 +96,29 @@ impl Renderable for DelayedLoadingHeader { } } +const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; + +struct PluginDisclosureLine { + line: Line<'static>, +} + +impl Renderable for PluginDisclosureLine { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .render(area, buf); + mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(u16::MAX) + } +} + #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { #[default] @@ -812,13 +838,37 @@ impl ChatWidget { ) -> SelectionViewParams { let marketplace_label = plugin.marketplace_name.clone(); let display_name = plugin_display_name(&plugin.summary); - let status_label = plugin_status_label(&plugin.summary); + let detail_status_label = if plugin.summary.installed { + if plugin.summary.enabled { + "Installed" + } else { + "Installed · Disabled" + } + } else { + match plugin.summary.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + }; let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( - format!("{display_name} · {marketplace_label}").bold(), + format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), )); - header.push(Line::from(status_label.dim())); + if !plugin.summary.installed { + header.push(PluginDisclosureLine { + line: Line::from(vec![ + "Data shared with this app is subject to the app's ".into(), + "terms of service".bold(), + " and ".into(), + "privacy policy".bold(), + ". ".into(), + "Learn more".cyan().underlined(), + ".".into(), + ]), + }); + } if let Some(description) = plugin_detail_description(plugin) { header.push(Line::from(description.dim())); } diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap index 399f16079..487141a2a 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap @@ -3,8 +3,9 @@ source: tui_app_server/src/chatwidget/tests.rs expression: popup --- Plugins - Figma · ChatGPT Marketplace - Available + Figma · Can be installed · ChatGPT Marketplace + Data shared with this app is subject to the app's terms of service and privacy policy. Learn + more. Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installed.snap new file mode 100644 index 000000000..651a323aa --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installed.snap @@ -0,0 +1,15 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · Installed · ChatGPT Marketplace + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Uninstall plugin Remove this plugin now. + Skills design-review, extract-copy + Apps Figma, Slack + MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index a68480612..c3c2ef769 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7680,6 +7680,40 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn strip_osc8_for_snapshot(text: &str) -> String { + // Snapshots should assert the visible popup text, not terminal hyperlink escapes. + let bytes = text.as_bytes(); + let mut stripped = String::with_capacity(text.len()); + let mut i = 0; + + while i < bytes.len() { + if bytes[i..].starts_with(b"\x1B]8;;") { + i += 5; + while i < bytes.len() { + if bytes[i] == b'\x07' { + i += 1; + break; + } + if i + 1 < bytes.len() && bytes[i] == b'\x1B' && bytes[i + 1] == b'\\' { + i += 2; + break; + } + i += 1; + } + continue; + } + + let ch = text[i..] + .chars() + .next() + .expect("slice should always contain a char"); + stripped.push(ch); + i += ch.len_utf8(); + } + + stripped +} + fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from( std::env::temp_dir() @@ -7935,7 +7969,54 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa ); let popup = render_bottom_popup(&chat, 100); - assert_snapshot!("plugin_detail_popup_installable", popup); + assert_snapshot!( + "plugin_detail_popup_installable", + strip_osc8_for_snapshot(&popup) + ); +} + +#[tokio::test] +async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + true, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert!( + !popup.contains("Data shared with this app is subject to the app's"), + "expected installed plugin details to hide the disclosure line, got:\n{popup}" + ); + assert_snapshot!( + "plugin_detail_popup_installed", + strip_osc8_for_snapshot(&popup) + ); } #[tokio::test] diff --git a/codex-rs/tui_app_server/src/onboarding/mod.rs b/codex-rs/tui_app_server/src/onboarding/mod.rs index d4cfd6d1f..8a4dc760f 100644 --- a/codex-rs/tui_app_server/src/onboarding/mod.rs +++ b/codex-rs/tui_app_server/src/onboarding/mod.rs @@ -1,5 +1,6 @@ mod auth; pub mod onboarding_screen; mod trust_directory; +pub(crate) use auth::mark_url_hyperlink; pub use trust_directory::TrustDirectorySelection; mod welcome;