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

<img width="1970" height="498" alt="image"
src="https://github.com/user-attachments/assets/f14330f7-661e-4860-8538-6dc9e8bbd90a"
/>
This commit is contained in:
canvrno-oai
2026-03-24 17:40:26 -07:00
committed by GitHub
parent 05b967c79a
commit 38b638d89d
10 changed files with 308 additions and 12 deletions
+53 -3
View File
@@ -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()));
}
@@ -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.
@@ -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.
+82 -1
View File
@@ -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]
+1
View File
@@ -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;
@@ -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()));
}
@@ -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.
@@ -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.
@@ -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]
@@ -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;