mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
## What changed - Accept two-stroke bindings such as `ctrl-x ctrl-s` in TUI keymap configuration while preserving arrays as alternative bindings. - Route chords through the active TUI context, show pending and configured chord hints, and cancel pending chords on `esc`, context changes, or a one-second timeout. - Extend `/keymap` to capture, add, replace, and display key chords. - Reject chords that conflict with active single-key bindings, overlapping-context chords, or reserved terminal shortcuts. ## Testing - Add coverage for chord parsing, validation, matching, timeout and cancellation behavior, context dispatch, key capture, and rendered shortcut hints. GitOrigin-RevId: dfdede03f518d4936ad8621ebf5537026f2b282e
1729 lines
63 KiB
Rust
1729 lines
63 KiB
Rust
#[cfg(test)]
|
||
use crate::app_command::AppCommand as Op;
|
||
use codex_app_server_protocol::McpServerElicitationAction;
|
||
use codex_app_server_protocol::RequestId as AppServerRequestId;
|
||
use codex_protocol::ThreadId;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Constraint;
|
||
use ratatui::layout::Layout;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Stylize;
|
||
use ratatui::text::Line;
|
||
use ratatui::widgets::Block;
|
||
use ratatui::widgets::Paragraph;
|
||
use ratatui::widgets::Widget;
|
||
use ratatui::widgets::Wrap;
|
||
use textwrap::wrap;
|
||
use url::Url;
|
||
|
||
use super::CancellationEvent;
|
||
use super::bottom_pane_view::BottomPaneView;
|
||
use super::scroll_state::ScrollState;
|
||
use super::selection_popup_common::GenericDisplayRow;
|
||
use super::selection_popup_common::measure_rows_height;
|
||
use super::selection_popup_common::render_rows;
|
||
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
||
use crate::app_event::AppEvent;
|
||
use crate::app_event_sender::AppEventSender;
|
||
use crate::key_hint;
|
||
use crate::keymap::ListAction;
|
||
use crate::keymap::ListKeymap;
|
||
use crate::render::Insets;
|
||
use crate::render::RectExt as _;
|
||
use crate::style::user_message_style;
|
||
use crate::wrapping::RtOptions;
|
||
use crate::wrapping::adaptive_wrap_lines;
|
||
|
||
const MCP_CODEX_APPS_SERVER_NAME: &str = "codex_apps";
|
||
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
|
||
const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure";
|
||
const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure";
|
||
const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id";
|
||
const CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY: &str = "connector_name";
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum AppLinkScreen {
|
||
Link,
|
||
InstallConfirmation,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub(crate) enum AppLinkSuggestionType {
|
||
Install,
|
||
Enable,
|
||
Auth,
|
||
ExternalAction,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub(crate) struct AppLinkElicitationTarget {
|
||
pub(crate) thread_id: ThreadId,
|
||
pub(crate) server_name: String,
|
||
pub(crate) request_id: AppServerRequestId,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub(crate) struct AppLinkViewParams {
|
||
pub(crate) app_id: String,
|
||
pub(crate) title: String,
|
||
pub(crate) description: Option<String>,
|
||
pub(crate) instructions: String,
|
||
pub(crate) url: String,
|
||
pub(crate) is_installed: bool,
|
||
pub(crate) is_enabled: bool,
|
||
pub(crate) suggest_reason: Option<String>,
|
||
pub(crate) suggestion_type: Option<AppLinkSuggestionType>,
|
||
pub(crate) elicitation_target: Option<AppLinkElicitationTarget>,
|
||
}
|
||
|
||
impl AppLinkViewParams {
|
||
pub(crate) fn from_url_app_server_request(
|
||
thread_id: ThreadId,
|
||
server_name: &str,
|
||
request_id: AppServerRequestId,
|
||
request: &codex_app_server_protocol::McpServerElicitationRequest,
|
||
) -> Option<Self> {
|
||
let codex_app_server_protocol::McpServerElicitationRequest::Url {
|
||
meta,
|
||
message,
|
||
url,
|
||
elicitation_id,
|
||
} = request
|
||
else {
|
||
return None;
|
||
};
|
||
if server_name == MCP_CODEX_APPS_SERVER_NAME {
|
||
let url = validate_external_url(url, /*require_chatgpt_host*/ true)?;
|
||
return Self::from_codex_apps_auth_url_parts(
|
||
thread_id,
|
||
server_name,
|
||
request_id,
|
||
meta.as_ref(),
|
||
message,
|
||
url.as_str(),
|
||
elicitation_id,
|
||
);
|
||
}
|
||
|
||
let url = validate_external_url(url, /*require_chatgpt_host*/ false)?;
|
||
Some(Self::from_generic_url_parts(
|
||
thread_id,
|
||
server_name,
|
||
request_id,
|
||
message,
|
||
url.as_str(),
|
||
elicitation_id,
|
||
))
|
||
}
|
||
|
||
fn from_codex_apps_auth_url_parts(
|
||
thread_id: ThreadId,
|
||
server_name: &str,
|
||
request_id: AppServerRequestId,
|
||
meta: Option<&serde_json::Value>,
|
||
message: &str,
|
||
url: &str,
|
||
elicitation_id: &str,
|
||
) -> Option<Self> {
|
||
let auth_failure = meta?
|
||
.as_object()?
|
||
.get(MCP_TOOL_CODEX_APPS_META_KEY)?
|
||
.as_object()?
|
||
.get(CONNECTOR_AUTH_FAILURE_META_KEY)?
|
||
.as_object()?;
|
||
if auth_failure
|
||
.get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY)
|
||
.and_then(serde_json::Value::as_bool)
|
||
!= Some(true)
|
||
{
|
||
return None;
|
||
}
|
||
|
||
let app_id = auth_failure
|
||
.get(CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY)
|
||
.and_then(serde_json::Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(elicitation_id)
|
||
.to_string();
|
||
let title = auth_failure
|
||
.get(CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY)
|
||
.and_then(serde_json::Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.unwrap_or(app_id.as_str())
|
||
.to_string();
|
||
|
||
Some(Self {
|
||
app_id,
|
||
title,
|
||
description: None,
|
||
instructions: "Sign in to this app in your browser, then return here.".to_string(),
|
||
url: url.to_string(),
|
||
is_installed: true,
|
||
is_enabled: true,
|
||
suggest_reason: Some(message.to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Auth),
|
||
elicitation_target: Some(AppLinkElicitationTarget {
|
||
thread_id,
|
||
server_name: server_name.to_string(),
|
||
request_id,
|
||
}),
|
||
})
|
||
}
|
||
|
||
fn from_generic_url_parts(
|
||
thread_id: ThreadId,
|
||
server_name: &str,
|
||
request_id: AppServerRequestId,
|
||
message: &str,
|
||
url: &str,
|
||
elicitation_id: &str,
|
||
) -> Self {
|
||
Self {
|
||
app_id: elicitation_id.to_string(),
|
||
title: "Action required".to_string(),
|
||
description: Some(format!("Server: {server_name}")),
|
||
instructions: "Complete the requested action in your browser, then return here."
|
||
.to_string(),
|
||
url: url.to_string(),
|
||
is_installed: true,
|
||
is_enabled: true,
|
||
suggest_reason: Some(message.to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::ExternalAction),
|
||
elicitation_target: Some(AppLinkElicitationTarget {
|
||
thread_id,
|
||
server_name: server_name.to_string(),
|
||
request_id,
|
||
}),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn validate_external_url(url: &str, require_chatgpt_host: bool) -> Option<Url> {
|
||
let parsed = Url::parse(url).ok()?;
|
||
if parsed.scheme() != "https" || parsed.host_str().is_none() {
|
||
return None;
|
||
}
|
||
if !parsed.username().is_empty() || parsed.password().is_some() {
|
||
return None;
|
||
}
|
||
if require_chatgpt_host && !is_allowed_chatgpt_auth_host(parsed.host_str()?) {
|
||
return None;
|
||
}
|
||
Some(parsed)
|
||
}
|
||
|
||
fn is_allowed_chatgpt_auth_host(host: &str) -> bool {
|
||
let host = host.to_ascii_lowercase();
|
||
host == "chatgpt.com"
|
||
|| host == "chatgpt-staging.com"
|
||
|| host.ends_with(".chatgpt.com")
|
||
|| host.ends_with(".chatgpt-staging.com")
|
||
}
|
||
|
||
pub(crate) struct AppLinkView {
|
||
app_id: String,
|
||
title: String,
|
||
description: Option<String>,
|
||
instructions: String,
|
||
url: String,
|
||
is_installed: bool,
|
||
is_enabled: bool,
|
||
suggest_reason: Option<String>,
|
||
suggestion_type: Option<AppLinkSuggestionType>,
|
||
elicitation_target: Option<AppLinkElicitationTarget>,
|
||
app_event_tx: AppEventSender,
|
||
screen: AppLinkScreen,
|
||
selected_action: usize,
|
||
complete: bool,
|
||
list_keymap: ListKeymap,
|
||
}
|
||
|
||
impl AppLinkView {
|
||
#[cfg(test)]
|
||
pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self {
|
||
Self::new_with_keymap(
|
||
params,
|
||
app_event_tx,
|
||
crate::keymap::RuntimeKeymap::defaults().list,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn new_with_keymap(
|
||
params: AppLinkViewParams,
|
||
app_event_tx: AppEventSender,
|
||
list_keymap: ListKeymap,
|
||
) -> Self {
|
||
let AppLinkViewParams {
|
||
app_id,
|
||
title,
|
||
description,
|
||
instructions,
|
||
url,
|
||
is_installed,
|
||
is_enabled,
|
||
suggest_reason,
|
||
suggestion_type,
|
||
elicitation_target,
|
||
} = params;
|
||
Self {
|
||
app_id,
|
||
title,
|
||
description,
|
||
instructions,
|
||
url,
|
||
is_installed,
|
||
is_enabled,
|
||
suggest_reason,
|
||
suggestion_type,
|
||
elicitation_target,
|
||
app_event_tx,
|
||
screen: AppLinkScreen::Link,
|
||
selected_action: 0,
|
||
complete: false,
|
||
list_keymap,
|
||
}
|
||
}
|
||
|
||
fn action_labels(&self) -> Vec<&'static str> {
|
||
if self.is_auth_suggestion() {
|
||
return match self.screen {
|
||
AppLinkScreen::Link => vec!["Open sign-in URL", "Back"],
|
||
AppLinkScreen::InstallConfirmation => vec!["I already signed in", "Back"],
|
||
};
|
||
}
|
||
if self.is_external_action_suggestion() {
|
||
return match self.screen {
|
||
AppLinkScreen::Link => vec!["Open link", "Back"],
|
||
AppLinkScreen::InstallConfirmation => vec!["I finished", "Back"],
|
||
};
|
||
}
|
||
|
||
match self.screen {
|
||
AppLinkScreen::Link => {
|
||
if self.is_installed {
|
||
vec![
|
||
"Manage on ChatGPT",
|
||
if self.is_enabled {
|
||
"Disable app"
|
||
} else {
|
||
"Enable app"
|
||
},
|
||
"Back",
|
||
]
|
||
} else {
|
||
vec!["Install on ChatGPT", "Back"]
|
||
}
|
||
}
|
||
AppLinkScreen::InstallConfirmation => vec!["I already Installed it", "Back"],
|
||
}
|
||
}
|
||
|
||
fn move_selection_prev(&mut self) {
|
||
self.selected_action = self.selected_action.saturating_sub(1);
|
||
}
|
||
|
||
fn move_selection_next(&mut self) {
|
||
self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
|
||
}
|
||
|
||
fn is_tool_suggestion(&self) -> bool {
|
||
self.elicitation_target.is_some()
|
||
}
|
||
|
||
fn is_auth_suggestion(&self) -> bool {
|
||
self.is_tool_suggestion() && self.suggestion_type == Some(AppLinkSuggestionType::Auth)
|
||
}
|
||
|
||
fn is_external_action_suggestion(&self) -> bool {
|
||
self.is_tool_suggestion()
|
||
&& self.suggestion_type == Some(AppLinkSuggestionType::ExternalAction)
|
||
}
|
||
|
||
fn is_browser_action_suggestion(&self) -> bool {
|
||
self.is_auth_suggestion() || self.is_external_action_suggestion()
|
||
}
|
||
|
||
fn resolve_elicitation(&self, decision: McpServerElicitationAction) {
|
||
let Some(target) = self.elicitation_target.as_ref() else {
|
||
return;
|
||
};
|
||
self.app_event_tx.resolve_elicitation(
|
||
target.thread_id,
|
||
target.server_name.clone(),
|
||
target.request_id.clone(),
|
||
decision,
|
||
/*content*/ None,
|
||
/*meta*/ None,
|
||
);
|
||
}
|
||
|
||
fn decline_tool_suggestion(&mut self) {
|
||
self.resolve_elicitation(McpServerElicitationAction::Decline);
|
||
self.complete = true;
|
||
}
|
||
|
||
fn open_external_url(&mut self) {
|
||
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
|
||
url: self.url.clone(),
|
||
});
|
||
if !self.is_installed || self.is_browser_action_suggestion() {
|
||
self.screen = AppLinkScreen::InstallConfirmation;
|
||
self.selected_action = 0;
|
||
}
|
||
}
|
||
|
||
fn complete_external_flow_and_close(&mut self) {
|
||
let should_refresh_connectors = self
|
||
.elicitation_target
|
||
.as_ref()
|
||
.is_none_or(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME);
|
||
if should_refresh_connectors {
|
||
self.app_event_tx.send(AppEvent::RefreshConnectors {
|
||
force_refetch: true,
|
||
});
|
||
}
|
||
if self.is_tool_suggestion() {
|
||
self.resolve_elicitation(McpServerElicitationAction::Accept);
|
||
}
|
||
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,
|
||
});
|
||
if self.is_tool_suggestion() {
|
||
self.resolve_elicitation(McpServerElicitationAction::Accept);
|
||
self.complete = true;
|
||
}
|
||
}
|
||
|
||
fn activate_selected_action(&mut self) {
|
||
if self.is_tool_suggestion() {
|
||
match self.suggestion_type {
|
||
Some(AppLinkSuggestionType::Enable) => match self.screen {
|
||
AppLinkScreen::Link => match self.selected_action {
|
||
0 => self.open_external_url(),
|
||
1 if self.is_installed => self.toggle_enabled(),
|
||
_ => self.decline_tool_suggestion(),
|
||
},
|
||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||
0 => self.complete_external_flow_and_close(),
|
||
_ => self.decline_tool_suggestion(),
|
||
},
|
||
},
|
||
Some(AppLinkSuggestionType::Auth) => match self.screen {
|
||
AppLinkScreen::Link => match self.selected_action {
|
||
0 => self.open_external_url(),
|
||
_ => self.decline_tool_suggestion(),
|
||
},
|
||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||
0 => self.complete_external_flow_and_close(),
|
||
_ => self.decline_tool_suggestion(),
|
||
},
|
||
},
|
||
Some(AppLinkSuggestionType::ExternalAction) => match self.screen {
|
||
AppLinkScreen::Link => match self.selected_action {
|
||
0 => self.open_external_url(),
|
||
_ => self.decline_tool_suggestion(),
|
||
},
|
||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||
0 => self.complete_external_flow_and_close(),
|
||
_ => self.decline_tool_suggestion(),
|
||
},
|
||
},
|
||
Some(AppLinkSuggestionType::Install) | None => match self.screen {
|
||
AppLinkScreen::Link => match self.selected_action {
|
||
0 => self.open_external_url(),
|
||
_ => self.decline_tool_suggestion(),
|
||
},
|
||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||
0 => self.complete_external_flow_and_close(),
|
||
_ => self.decline_tool_suggestion(),
|
||
},
|
||
},
|
||
}
|
||
return;
|
||
}
|
||
|
||
match self.screen {
|
||
AppLinkScreen::Link => match self.selected_action {
|
||
0 => self.open_external_url(),
|
||
1 if self.is_installed => self.toggle_enabled(),
|
||
_ => self.complete = true,
|
||
},
|
||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||
0 => self.complete_external_flow_and_close(),
|
||
_ => self.back_to_link_screen(),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||
match self.screen {
|
||
AppLinkScreen::Link => self.link_content_lines(width),
|
||
AppLinkScreen::InstallConfirmation => self.install_confirmation_lines(width),
|
||
}
|
||
}
|
||
|
||
fn link_content_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||
let usable_width = width.max(1) as usize;
|
||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||
|
||
lines.push(Line::from(self.title.clone().bold()));
|
||
if let Some(description) = self
|
||
.description
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|description| !description.is_empty())
|
||
{
|
||
for line in wrap(description, usable_width) {
|
||
lines.push(Line::from(line.into_owned().dim()));
|
||
}
|
||
}
|
||
|
||
lines.push(Line::from(""));
|
||
if let Some(suggest_reason) = self
|
||
.suggest_reason
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|suggest_reason| !suggest_reason.is_empty())
|
||
{
|
||
for line in wrap(suggest_reason, usable_width) {
|
||
lines.push(Line::from(line.into_owned().italic()));
|
||
}
|
||
lines.push(Line::from(""));
|
||
}
|
||
let is_browser_action_suggestion = self.is_browser_action_suggestion();
|
||
if self.is_installed && !is_browser_action_suggestion {
|
||
for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
lines.push(Line::from(""));
|
||
}
|
||
|
||
if is_browser_action_suggestion {
|
||
lines.push(Line::from("URL".dim()));
|
||
for line in wrap(&self.url, usable_width) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
lines.push(Line::from(""));
|
||
}
|
||
|
||
let instructions = self.instructions.trim();
|
||
if !instructions.is_empty() {
|
||
for line in wrap(instructions, usable_width) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
if !is_browser_action_suggestion {
|
||
for line in wrap(
|
||
"Newly installed apps can take a few minutes to appear in /apps.",
|
||
usable_width,
|
||
) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
if !self.is_installed {
|
||
for line in wrap(
|
||
"After installed, use $ to insert this app into the prompt.",
|
||
usable_width,
|
||
) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
}
|
||
}
|
||
lines.push(Line::from(""));
|
||
}
|
||
|
||
lines
|
||
}
|
||
|
||
fn install_confirmation_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||
let usable_width = width.max(1) as usize;
|
||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||
|
||
let is_auth_suggestion = self.is_auth_suggestion();
|
||
let is_external_action_suggestion = self.is_external_action_suggestion();
|
||
let is_codex_apps_auth = is_auth_suggestion
|
||
&& self
|
||
.elicitation_target
|
||
.as_ref()
|
||
.is_some_and(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME);
|
||
lines.push(Line::from(
|
||
if is_auth_suggestion {
|
||
if is_codex_apps_auth {
|
||
"Finish App Sign In"
|
||
} else {
|
||
"Finish Authentication"
|
||
}
|
||
} else if is_external_action_suggestion {
|
||
"Finish in Browser"
|
||
} else {
|
||
"Finish App Setup"
|
||
}
|
||
.bold(),
|
||
));
|
||
lines.push(Line::from(""));
|
||
|
||
if is_auth_suggestion {
|
||
for line in wrap(
|
||
if is_codex_apps_auth {
|
||
"Sign in to the app on ChatGPT in the browser window that just opened."
|
||
} else {
|
||
"Complete authentication in the browser window that just opened."
|
||
},
|
||
usable_width,
|
||
) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
for line in wrap(
|
||
"Then return here and select \"I already signed in\".",
|
||
usable_width,
|
||
) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
} else if is_external_action_suggestion {
|
||
for line in wrap(
|
||
"Complete the requested action in the browser window that just opened.",
|
||
usable_width,
|
||
) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
for line in wrap("Then return here and select \"I finished\".", usable_width) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
} else {
|
||
for line in wrap(
|
||
"Complete app setup on ChatGPT in the browser window that just opened.",
|
||
usable_width,
|
||
) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
for line in wrap(
|
||
"Sign in there if needed, then return here and select \"I already Installed it\".",
|
||
usable_width,
|
||
) {
|
||
lines.push(Line::from(line.into_owned()));
|
||
}
|
||
}
|
||
|
||
lines.push(Line::from(""));
|
||
lines.push(Line::from(vec![
|
||
if is_auth_suggestion {
|
||
"Sign-in URL:"
|
||
} else if is_external_action_suggestion {
|
||
"Link:"
|
||
} else {
|
||
"Setup URL:"
|
||
}
|
||
.dim(),
|
||
]));
|
||
let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
|
||
lines.extend(adaptive_wrap_lines(
|
||
vec![url_line],
|
||
RtOptions::new(usable_width),
|
||
));
|
||
|
||
lines
|
||
}
|
||
|
||
fn action_rows(&self) -> Vec<GenericDisplayRow> {
|
||
self.action_labels()
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(index, label)| {
|
||
let prefix = if self.selected_action == index {
|
||
'›'
|
||
} else {
|
||
' '
|
||
};
|
||
GenericDisplayRow {
|
||
name: format!("{prefix} {}. {label}", index + 1),
|
||
..Default::default()
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn action_state(&self) -> ScrollState {
|
||
let mut state = ScrollState::new();
|
||
state.selected_idx = Some(self.selected_action);
|
||
state
|
||
}
|
||
|
||
fn action_rows_height(&self, width: u16) -> u16 {
|
||
let rows = self.action_rows();
|
||
let state = self.action_state();
|
||
measure_rows_height(&rows, &state, rows.len().max(1), width.max(1))
|
||
}
|
||
|
||
fn hint_line(&self) -> Line<'static> {
|
||
let mut spans = vec!["Use ".into(), key_hint::plain(KeyCode::Tab).into()];
|
||
if let Some(move_up) = self.list_keymap.primary_hint(ListAction::MoveUp) {
|
||
spans.extend([" / ".into(), move_up.into()]);
|
||
}
|
||
if let Some(move_down) = self.list_keymap.primary_hint(ListAction::MoveDown) {
|
||
spans.extend([" ".into(), move_down.into()]);
|
||
}
|
||
spans.push(" to move".into());
|
||
if let Some(accept) = self.list_keymap.primary_hint(ListAction::Accept) {
|
||
spans.extend([", ".into(), accept.into(), " to select".into()]);
|
||
}
|
||
if let Some(cancel) = self.list_keymap.primary_hint(ListAction::Cancel) {
|
||
spans.extend([", ".into(), cancel.into(), " to close".into()]);
|
||
}
|
||
Line::from(spans)
|
||
}
|
||
}
|
||
|
||
impl BottomPaneView for AppLinkView {
|
||
fn keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
|
||
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
|
||
}
|
||
|
||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||
match key_event {
|
||
KeyEvent {
|
||
code: KeyCode::Esc, ..
|
||
} => {
|
||
self.on_ctrl_c();
|
||
}
|
||
_ if self.list_keymap.action_for(key_event) == Some(ListAction::Cancel) => {
|
||
self.on_ctrl_c();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Up, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::BackTab,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('k'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => self.move_selection_prev(),
|
||
_ if matches!(
|
||
self.list_keymap.action_for(key_event),
|
||
Some(ListAction::MoveUp | ListAction::MoveLeft)
|
||
) =>
|
||
{
|
||
self.move_selection_prev()
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Down,
|
||
..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Tab, ..
|
||
}
|
||
| KeyEvent {
|
||
code: KeyCode::Char('j'),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => self.move_selection_next(),
|
||
_ if matches!(
|
||
self.list_keymap.action_for(key_event),
|
||
Some(ListAction::MoveDown | ListAction::MoveRight)
|
||
) =>
|
||
{
|
||
self.move_selection_next()
|
||
}
|
||
_ if self.list_keymap.action_for(key_event) == Some(ListAction::Accept) => {
|
||
self.activate_selected_action();
|
||
}
|
||
KeyEvent {
|
||
code: KeyCode::Char(c),
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => {
|
||
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,
|
||
modifiers: KeyModifiers::NONE,
|
||
..
|
||
} => self.activate_selected_action(),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||
if self.is_tool_suggestion() {
|
||
self.resolve_elicitation(McpServerElicitationAction::Decline);
|
||
}
|
||
self.complete = true;
|
||
CancellationEvent::Handled
|
||
}
|
||
|
||
fn is_complete(&self) -> bool {
|
||
self.complete
|
||
}
|
||
|
||
fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool {
|
||
let ResolvedAppServerRequest::McpElicitation {
|
||
server_name,
|
||
request_id,
|
||
} = request
|
||
else {
|
||
return false;
|
||
};
|
||
let Some(target) = self.elicitation_target.as_ref() else {
|
||
return false;
|
||
};
|
||
if target.server_name != *server_name || target.request_id != *request_id {
|
||
return false;
|
||
}
|
||
|
||
self.complete = true;
|
||
true
|
||
}
|
||
|
||
fn terminal_title_requires_action(&self) -> bool {
|
||
self.is_tool_suggestion()
|
||
}
|
||
}
|
||
|
||
impl crate::render::renderable::Renderable for AppLinkView {
|
||
fn desired_height(&self, width: u16) -> u16 {
|
||
let content_width = width.saturating_sub(4).max(1);
|
||
let content_lines = self.content_lines(content_width);
|
||
let content_rows = Paragraph::new(content_lines)
|
||
.wrap(Wrap { trim: false })
|
||
.line_count(content_width)
|
||
.max(1) as u16;
|
||
let action_rows_height = self.action_rows_height(content_width);
|
||
content_rows + action_rows_height + 3
|
||
}
|
||
|
||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||
if area.height == 0 || area.width == 0 {
|
||
return;
|
||
}
|
||
|
||
Block::default()
|
||
.style(user_message_style())
|
||
.render(area, buf);
|
||
|
||
let actions_height = self.action_rows_height(area.width.saturating_sub(4));
|
||
let [content_area, actions_area, hint_area] = Layout::vertical([
|
||
Constraint::Fill(1),
|
||
Constraint::Length(actions_height),
|
||
Constraint::Length(1),
|
||
])
|
||
.areas(area);
|
||
|
||
let inner = content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2));
|
||
let content_width = inner.width.max(1);
|
||
let lines = self.content_lines(content_width);
|
||
Paragraph::new(lines)
|
||
.wrap(Wrap { trim: false })
|
||
.render(inner, buf);
|
||
crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url);
|
||
|
||
if actions_area.height > 0 {
|
||
let actions_area = Rect {
|
||
x: actions_area.x.saturating_add(2),
|
||
y: actions_area.y,
|
||
width: actions_area.width.saturating_sub(2),
|
||
height: actions_area.height,
|
||
};
|
||
let action_rows = self.action_rows();
|
||
let action_state = self.action_state();
|
||
render_rows(
|
||
actions_area,
|
||
buf,
|
||
&action_rows,
|
||
&action_state,
|
||
action_rows.len().max(1),
|
||
"No actions",
|
||
);
|
||
}
|
||
|
||
if hint_area.height > 0 {
|
||
let hint_area = Rect {
|
||
x: hint_area.x.saturating_add(2),
|
||
y: hint_area.y,
|
||
width: hint_area.width.saturating_sub(2),
|
||
height: hint_area.height,
|
||
};
|
||
self.hint_line().dim().render(hint_area, buf);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::app::app_server_requests::ResolvedAppServerRequest;
|
||
use crate::app_event::AppEvent;
|
||
use crate::render::renderable::Renderable;
|
||
use insta::assert_snapshot;
|
||
use pretty_assertions::assert_eq;
|
||
use tokio::sync::mpsc::unbounded_channel;
|
||
|
||
fn suggestion_target() -> AppLinkElicitationTarget {
|
||
AppLinkElicitationTarget {
|
||
thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001")
|
||
.expect("valid thread id"),
|
||
server_name: "codex_apps".to_string(),
|
||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||
}
|
||
}
|
||
|
||
fn generic_url_target() -> AppLinkElicitationTarget {
|
||
AppLinkElicitationTarget {
|
||
thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002")
|
||
.expect("valid thread id"),
|
||
server_name: "payments".to_string(),
|
||
request_id: AppServerRequestId::String("request-2".to_string()),
|
||
}
|
||
}
|
||
|
||
fn auth_url_request(url: &str) -> codex_app_server_protocol::McpServerElicitationRequest {
|
||
codex_app_server_protocol::McpServerElicitationRequest::Url {
|
||
meta: Some(serde_json::json!({
|
||
"_codex_apps": {
|
||
"connector_auth_failure": {
|
||
"is_auth_failure": true,
|
||
"connector_id": "connector_calendar",
|
||
"connector_name": "Google Calendar",
|
||
},
|
||
},
|
||
})),
|
||
message: "Reconnect Google Calendar on ChatGPT.".to_string(),
|
||
url: url.to_string(),
|
||
elicitation_id: "codex_apps_auth_call_123".to_string(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn codex_apps_auth_url_elicitation_builds_auth_app_link_params() {
|
||
let target = suggestion_target();
|
||
let request =
|
||
auth_url_request("https://chatgpt.com/apps/google-calendar/connector_calendar");
|
||
|
||
let params = AppLinkViewParams::from_url_app_server_request(
|
||
target.thread_id,
|
||
&target.server_name,
|
||
target.request_id.clone(),
|
||
&request,
|
||
)
|
||
.expect("expected auth app link params");
|
||
|
||
assert_eq!(params.app_id, "connector_calendar");
|
||
assert_eq!(params.title, "Google Calendar");
|
||
assert_eq!(
|
||
params.url,
|
||
"https://chatgpt.com/apps/google-calendar/connector_calendar"
|
||
);
|
||
assert_eq!(params.suggestion_type, Some(AppLinkSuggestionType::Auth));
|
||
assert_eq!(params.elicitation_target, Some(target));
|
||
}
|
||
|
||
#[test]
|
||
fn non_codex_apps_url_elicitation_builds_generic_app_link_params() {
|
||
let target = generic_url_target();
|
||
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
|
||
meta: None,
|
||
message: "Review the payment details to continue.".to_string(),
|
||
url: "https://payments.example/checkout/123".to_string(),
|
||
elicitation_id: "payment-123".to_string(),
|
||
};
|
||
|
||
let params = AppLinkViewParams::from_url_app_server_request(
|
||
target.thread_id,
|
||
&target.server_name,
|
||
target.request_id.clone(),
|
||
&request,
|
||
)
|
||
.expect("expected generic URL app link params");
|
||
|
||
assert_eq!(
|
||
params,
|
||
AppLinkViewParams {
|
||
app_id: "payment-123".to_string(),
|
||
title: "Action required".to_string(),
|
||
description: Some("Server: payments".to_string()),
|
||
instructions: "Complete the requested action in your browser, then return here."
|
||
.to_string(),
|
||
url: "https://payments.example/checkout/123".to_string(),
|
||
is_installed: true,
|
||
is_enabled: true,
|
||
suggest_reason: Some("Review the payment details to continue.".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::ExternalAction),
|
||
elicitation_target: Some(target),
|
||
}
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn codex_apps_auth_url_elicitation_rejects_untrusted_urls() {
|
||
let target = suggestion_target();
|
||
for url in [
|
||
"http://chatgpt.com/apps/google-calendar/connector_calendar",
|
||
"https://user:pass@chatgpt.com/apps/google-calendar/connector_calendar",
|
||
"https://chatgpt.com.evil.example/apps/google-calendar/connector_calendar",
|
||
"https://evilchatgpt.com/apps/google-calendar/connector_calendar",
|
||
] {
|
||
let request = auth_url_request(url);
|
||
let params = AppLinkViewParams::from_url_app_server_request(
|
||
target.thread_id,
|
||
&target.server_name,
|
||
target.request_id.clone(),
|
||
&request,
|
||
);
|
||
assert!(params.is_none(), "expected {url} to be rejected");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn generic_url_elicitation_rejects_untrusted_urls() {
|
||
let target = generic_url_target();
|
||
for url in [
|
||
"http://payments.example/checkout/123",
|
||
"https://user:pass@payments.example/checkout/123",
|
||
] {
|
||
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
|
||
meta: None,
|
||
message: "Review the payment details to continue.".to_string(),
|
||
url: url.to_string(),
|
||
elicitation_id: "payment-123".to_string(),
|
||
};
|
||
let params = AppLinkViewParams::from_url_app_server_request(
|
||
target.thread_id,
|
||
&target.server_name,
|
||
target.request_id.clone(),
|
||
&request,
|
||
);
|
||
assert!(params.is_none(), "expected {url} to be rejected");
|
||
}
|
||
}
|
||
|
||
fn render_snapshot(view: &AppLinkView, area: Rect) -> String {
|
||
let mut buf = Buffer::empty(area);
|
||
view.render(area, &mut buf);
|
||
(0..area.height)
|
||
.map(|y| {
|
||
(0..area.width)
|
||
.map(|x| {
|
||
let symbol = buf[(x, y)].symbol();
|
||
if symbol.is_empty() {
|
||
' '
|
||
} else {
|
||
crate::terminal_hyperlinks::strip_osc8(symbol)
|
||
.chars()
|
||
.next()
|
||
.unwrap_or(' ')
|
||
}
|
||
})
|
||
.collect::<String>()
|
||
.trim_end()
|
||
.to_string()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n")
|
||
}
|
||
|
||
#[test]
|
||
fn installed_app_has_toggle_action() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
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,
|
||
suggest_reason: None,
|
||
suggestion_type: None,
|
||
elicitation_target: None,
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert_eq!(
|
||
view.action_labels(),
|
||
vec!["Manage on ChatGPT", "Disable app", "Back"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn regular_app_link_does_not_require_terminal_title_action() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
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,
|
||
suggest_reason: None,
|
||
suggestion_type: None,
|
||
elicitation_target: None,
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert!(!view.terminal_title_requires_action());
|
||
}
|
||
|
||
#[test]
|
||
fn tool_suggestion_requires_terminal_title_action() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: Some("Plan events and schedules.".to_string()),
|
||
instructions: "Enable this app to use it for the current request.".to_string(),
|
||
url: "https://example.test/google-calendar".to_string(),
|
||
is_installed: true,
|
||
is_enabled: false,
|
||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Enable),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert!(view.terminal_title_requires_action());
|
||
}
|
||
|
||
#[test]
|
||
fn horizontal_list_keys_move_action_selection() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
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,
|
||
suggest_reason: None,
|
||
suggestion_type: None,
|
||
elicitation_target: None,
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert_eq!(view.selected_action, 0);
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
|
||
assert_eq!(view.selected_action, 1);
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
|
||
assert_eq!(view.selected_action, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn remapped_horizontal_list_keys_control_action_selection() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let mut list_keymap = crate::keymap::RuntimeKeymap::defaults().list;
|
||
list_keymap.move_left = vec![key_hint::plain(KeyCode::Char('x'))];
|
||
list_keymap.move_right = vec![key_hint::plain(KeyCode::Char('z'))];
|
||
let mut view = AppLinkView::new_with_keymap(
|
||
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,
|
||
suggest_reason: None,
|
||
suggestion_type: None,
|
||
elicitation_target: None,
|
||
},
|
||
tx,
|
||
list_keymap,
|
||
);
|
||
|
||
assert_eq!(view.selected_action, 0);
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
|
||
assert_eq!(view.selected_action, 0);
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
|
||
assert_eq!(view.selected_action, 0);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
|
||
assert_eq!(view.selected_action, 1);
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
|
||
assert_eq!(view.selected_action, 1);
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||
assert_eq!(view.selected_action, 1);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||
assert_eq!(view.selected_action, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn toggle_action_sends_set_app_enabled_and_updates_label() {
|
||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||
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,
|
||
suggest_reason: None,
|
||
suggestion_type: None,
|
||
elicitation_target: None,
|
||
},
|
||
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"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generic_url_elicitation_resolves_without_connector_refresh() {
|
||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let target = generic_url_target();
|
||
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
|
||
meta: None,
|
||
message: "Review the payment details to continue.".to_string(),
|
||
url: "https://payments.example/checkout/123".to_string(),
|
||
elicitation_id: "payment-123".to_string(),
|
||
};
|
||
let params = AppLinkViewParams::from_url_app_server_request(
|
||
target.thread_id,
|
||
&target.server_name,
|
||
target.request_id.clone(),
|
||
&request,
|
||
)
|
||
.expect("expected generic URL app link params");
|
||
let mut view = AppLinkView::new(params, tx);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::OpenUrlInBrowser { url }) => {
|
||
assert_eq!(url, "https://payments.example/checkout/123");
|
||
}
|
||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||
Err(err) => panic!("missing app event: {err}"),
|
||
}
|
||
assert_eq!(view.screen, AppLinkScreen::InstallConfirmation);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||
assert_eq!(thread_id, target.thread_id);
|
||
assert_eq!(
|
||
op,
|
||
Op::ResolveElicitation {
|
||
server_name: "payments".to_string(),
|
||
request_id: AppServerRequestId::String("request-2".to_string()),
|
||
decision: McpServerElicitationAction::Accept,
|
||
content: None,
|
||
meta: None,
|
||
}
|
||
);
|
||
}
|
||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||
Err(err) => panic!("missing app event: {err}"),
|
||
}
|
||
assert!(rx.try_recv().is_err());
|
||
assert!(view.is_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn install_confirmation_does_not_split_long_url_like_token_without_scheme() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let url_like =
|
||
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
|
||
let mut view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_1".to_string(),
|
||
title: "Notion".to_string(),
|
||
description: None,
|
||
instructions: "Manage app".to_string(),
|
||
url: url_like.to_string(),
|
||
is_installed: true,
|
||
is_enabled: true,
|
||
suggest_reason: None,
|
||
suggestion_type: None,
|
||
elicitation_target: None,
|
||
},
|
||
tx,
|
||
);
|
||
view.screen = AppLinkScreen::InstallConfirmation;
|
||
|
||
let rendered: Vec<String> = view
|
||
.content_lines(/*width*/ 40)
|
||
.into_iter()
|
||
.map(|line| {
|
||
line.spans
|
||
.into_iter()
|
||
.map(|span| span.content.into_owned())
|
||
.collect::<String>()
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(
|
||
rendered
|
||
.iter()
|
||
.filter(|line| line.contains(url_like))
|
||
.count(),
|
||
1,
|
||
"expected full URL-like token in one rendered line, got: {rendered:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn install_confirmation_render_keeps_url_tail_visible_when_narrow() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42";
|
||
let mut view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_1".to_string(),
|
||
title: "Notion".to_string(),
|
||
description: None,
|
||
instructions: "Manage app".to_string(),
|
||
url: url.to_string(),
|
||
is_installed: true,
|
||
is_enabled: true,
|
||
suggest_reason: None,
|
||
suggestion_type: None,
|
||
elicitation_target: None,
|
||
},
|
||
tx,
|
||
);
|
||
view.screen = AppLinkScreen::InstallConfirmation;
|
||
|
||
let width: u16 = 36;
|
||
let height = view.desired_height(width);
|
||
let area = Rect::new(0, 0, width, height);
|
||
let mut buf = Buffer::empty(area);
|
||
view.render(area, &mut buf);
|
||
|
||
let rendered_blob = (0..area.height)
|
||
.map(|y| {
|
||
(0..area.width)
|
||
.map(|x| {
|
||
let symbol = buf[(x, y)].symbol();
|
||
if symbol.is_empty() {
|
||
' '
|
||
} else {
|
||
crate::terminal_hyperlinks::strip_osc8(symbol)
|
||
.chars()
|
||
.next()
|
||
.unwrap_or(' ')
|
||
}
|
||
})
|
||
.collect::<String>()
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
|
||
assert!(
|
||
rendered_blob.contains("tail42"),
|
||
"expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn install_tool_suggestion_resolves_elicitation_after_confirmation() {
|
||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let mut view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: Some("Plan events and schedules.".to_string()),
|
||
instructions: "Install this app in your browser, then return here.".to_string(),
|
||
url: "https://example.test/google-calendar".to_string(),
|
||
is_installed: false,
|
||
is_enabled: false,
|
||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Install),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
tx,
|
||
);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::OpenUrlInBrowser { url }) => {
|
||
assert_eq!(url, "https://example.test/google-calendar".to_string());
|
||
}
|
||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||
Err(err) => panic!("missing app event: {err}"),
|
||
}
|
||
assert_eq!(view.screen, AppLinkScreen::InstallConfirmation);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::RefreshConnectors { force_refetch }) => {
|
||
assert!(force_refetch);
|
||
}
|
||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||
Err(err) => panic!("missing app event: {err}"),
|
||
}
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||
assert_eq!(
|
||
op,
|
||
Op::ResolveElicitation {
|
||
server_name: "codex_apps".to_string(),
|
||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||
decision: McpServerElicitationAction::Accept,
|
||
content: None,
|
||
meta: None,
|
||
}
|
||
);
|
||
}
|
||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||
Err(err) => panic!("missing app event: {err}"),
|
||
}
|
||
assert!(view.is_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn declined_tool_suggestion_resolves_elicitation_decline() {
|
||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let mut view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: None,
|
||
instructions: "Install this app in your browser, then return here.".to_string(),
|
||
url: "https://example.test/google-calendar".to_string(),
|
||
is_installed: false,
|
||
is_enabled: false,
|
||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Install),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
tx,
|
||
);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||
assert_eq!(
|
||
op,
|
||
Op::ResolveElicitation {
|
||
server_name: "codex_apps".to_string(),
|
||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||
decision: McpServerElicitationAction::Decline,
|
||
content: None,
|
||
meta: None,
|
||
}
|
||
);
|
||
}
|
||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||
Err(err) => panic!("missing app event: {err}"),
|
||
}
|
||
assert!(view.is_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn enable_tool_suggestion_resolves_elicitation_after_enable() {
|
||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let mut view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: Some("Plan events and schedules.".to_string()),
|
||
instructions: "Enable this app to use it for the current request.".to_string(),
|
||
url: "https://example.test/google-calendar".to_string(),
|
||
is_installed: true,
|
||
is_enabled: false,
|
||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Enable),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
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_google_calendar");
|
||
assert!(enabled);
|
||
}
|
||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||
Err(err) => panic!("missing app event: {err}"),
|
||
}
|
||
match rx.try_recv() {
|
||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||
assert_eq!(
|
||
op,
|
||
Op::ResolveElicitation {
|
||
server_name: "codex_apps".to_string(),
|
||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||
decision: McpServerElicitationAction::Accept,
|
||
content: None,
|
||
meta: None,
|
||
}
|
||
);
|
||
}
|
||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||
Err(err) => panic!("missing app event: {err}"),
|
||
}
|
||
assert!(view.is_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn resolved_tool_suggestion_dismisses_matching_view() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let mut view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: Some("Plan events and schedules.".to_string()),
|
||
instructions: "Enable this app to use it for the current request.".to_string(),
|
||
url: "https://example.test/google-calendar".to_string(),
|
||
is_installed: true,
|
||
is_enabled: false,
|
||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Enable),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert!(
|
||
view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
|
||
server_name: "codex_apps".to_string(),
|
||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||
})
|
||
);
|
||
assert!(view.is_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn resolved_tool_suggestion_ignores_non_matching_request() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let mut view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: Some("Plan events and schedules.".to_string()),
|
||
instructions: "Enable this app to use it for the current request.".to_string(),
|
||
url: "https://example.test/google-calendar".to_string(),
|
||
is_installed: true,
|
||
is_enabled: false,
|
||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Enable),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert!(
|
||
!view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
|
||
server_name: "other_server".to_string(),
|
||
request_id: AppServerRequestId::String("request-1".to_string()),
|
||
})
|
||
);
|
||
assert!(!view.is_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn install_suggestion_with_reason_snapshot() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: Some("Plan events and schedules.".to_string()),
|
||
instructions: "Install this app in your browser, then return here.".to_string(),
|
||
url: "https://example.test/google-calendar".to_string(),
|
||
is_installed: false,
|
||
is_enabled: false,
|
||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Install),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert_snapshot!(
|
||
"app_link_view_install_suggestion_with_reason",
|
||
render_snapshot(
|
||
&view,
|
||
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
|
||
)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn enable_suggestion_with_reason_snapshot() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: Some("Plan events and schedules.".to_string()),
|
||
instructions: "Enable this app to use it for the current request.".to_string(),
|
||
url: "https://example.test/google-calendar".to_string(),
|
||
is_installed: true,
|
||
is_enabled: false,
|
||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Enable),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert_snapshot!(
|
||
"app_link_view_enable_suggestion_with_reason",
|
||
render_snapshot(
|
||
&view,
|
||
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
|
||
)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn auth_suggestion_with_reason_snapshot() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let view = AppLinkView::new(
|
||
AppLinkViewParams {
|
||
app_id: "connector_google_calendar".to_string(),
|
||
title: "Google Calendar".to_string(),
|
||
description: None,
|
||
instructions: "Sign in to this app in your browser, then return here.".to_string(),
|
||
url: "https://chatgpt.com/apps/google-calendar/connector_google_calendar"
|
||
.to_string(),
|
||
is_installed: true,
|
||
is_enabled: true,
|
||
suggest_reason: Some("Reconnect Google Calendar on ChatGPT.".to_string()),
|
||
suggestion_type: Some(AppLinkSuggestionType::Auth),
|
||
elicitation_target: Some(suggestion_target()),
|
||
},
|
||
tx,
|
||
);
|
||
|
||
assert_snapshot!(
|
||
"app_link_view_auth_suggestion_with_reason",
|
||
render_snapshot(
|
||
&view,
|
||
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
|
||
)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generic_url_elicitation_snapshot() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let target = generic_url_target();
|
||
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
|
||
meta: None,
|
||
message: "Review the payment details to continue.".to_string(),
|
||
url: "https://payments.example/checkout/123".to_string(),
|
||
elicitation_id: "payment-123".to_string(),
|
||
};
|
||
let params = AppLinkViewParams::from_url_app_server_request(
|
||
target.thread_id,
|
||
&target.server_name,
|
||
target.request_id.clone(),
|
||
&request,
|
||
)
|
||
.expect("expected generic URL app link params");
|
||
let view = AppLinkView::new(params, tx);
|
||
|
||
assert_snapshot!(
|
||
"app_link_view_generic_url_elicitation",
|
||
render_snapshot(
|
||
&view,
|
||
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
|
||
)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn generic_url_elicitation_confirmation_snapshot() {
|
||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let target = generic_url_target();
|
||
let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
|
||
meta: None,
|
||
message: "Review the payment details to continue.".to_string(),
|
||
url: "https://payments.example/checkout/123".to_string(),
|
||
elicitation_id: "payment-123".to_string(),
|
||
};
|
||
let params = AppLinkViewParams::from_url_app_server_request(
|
||
target.thread_id,
|
||
&target.server_name,
|
||
target.request_id.clone(),
|
||
&request,
|
||
)
|
||
.expect("expected generic URL app link params");
|
||
let mut view = AppLinkView::new(params, tx);
|
||
|
||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||
|
||
assert_snapshot!(
|
||
"app_link_view_generic_url_elicitation_confirmation",
|
||
render_snapshot(
|
||
&view,
|
||
Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
|
||
)
|
||
);
|
||
}
|
||
}
|