use std::collections::HashSet;use std::collections::VecDeque;use std::path::PathBuf;#[cfg(test)]use crate::app_command::AppCommand as Op;use codex_app_server_protocol::McpElicitationEnumSchema;use codex_app_server_protocol::McpElicitationPrimitiveSchema;use codex_app_server_protocol::McpElicitationSingleSelectEnumSchema;use codex_app_server_protocol::McpServerElicitationAction;use codex_app_server_protocol::McpServerElicitationRequest;use codex_app_server_protocol::McpServerElicitationRequestParams;use codex_app_server_protocol::RequestId as AppServerRequestId;use codex_protocol::ThreadId;use codex_protocol::mcp_approval_meta::APPROVAL_KIND_KEY as APPROVAL_META_KIND_KEY;use codex_protocol::mcp_approval_meta::APPROVAL_KIND_MCP_TOOL_CALL as APPROVAL_META_KIND_MCP_TOOL_CALL;use codex_protocol::mcp_approval_meta::APPROVAL_KIND_TOOL_SUGGESTION as APPROVAL_META_KIND_TOOL_SUGGESTION;use codex_protocol::mcp_approval_meta::PERSIST_ALWAYS as APPROVAL_PERSIST_ALWAYS_VALUE;use codex_protocol::mcp_approval_meta::PERSIST_KEY as APPROVAL_PERSIST_KEY;use codex_protocol::mcp_approval_meta::PERSIST_SESSION as APPROVAL_PERSIST_SESSION_VALUE;use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY;use codex_protocol::mcp_approval_meta::TOOL_PARAMS_DISPLAY_KEY as APPROVAL_TOOL_PARAMS_DISPLAY_KEY;use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as APPROVAL_TOOL_PARAMS_KEY;use codex_protocol::user_input::TextElement;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyEventKind;use crossterm::event::KeyModifiers;use ratatui::buffer::Buffer;use ratatui::layout::Rect;use ratatui::style::Stylize;use ratatui::text::Line;use ratatui::widgets::Paragraph;use ratatui::widgets::Widget;use serde_json::Value;use unicode_width::UnicodeWidthStr;use crate::app::app_server_requests::ResolvedAppServerRequest;use crate::app_event_sender::AppEventSender;use crate::bottom_pane::CancellationEvent;use crate::bottom_pane::ChatComposer;use crate::bottom_pane::ChatComposerConfig;use crate::bottom_pane::InputResult;use crate::bottom_pane::bottom_pane_view::BottomPaneView;use crate::bottom_pane::scroll_state::ScrollState;use crate::bottom_pane::selection_popup_common::GenericDisplayRow;use crate::bottom_pane::selection_popup_common::measure_rows_height;use crate::bottom_pane::selection_popup_common::menu_surface_inset;use crate::bottom_pane::selection_popup_common::menu_surface_padding_height;use crate::bottom_pane::selection_popup_common::render_menu_surface;use crate::bottom_pane::selection_popup_common::render_rows;use crate::key_hint::KeyBindingListExt;use crate::keymap::ListKeymap;use crate::render::renderable::Renderable;use crate::text_formatting::format_json_compact;use crate::text_formatting::truncate_text;const ANSWER_PLACEHOLDER: &str = "Type your answer";const OPTIONAL_ANSWER_PLACEHOLDER: &str = "Type your answer (optional)";const FOOTER_SEPARATOR: &str = " | ";const MIN_COMPOSER_HEIGHT: u16 = 3;const MIN_OVERLAY_HEIGHT: u16 = 8;const APPROVAL_FIELD_ID: &str = "__approval";const APPROVAL_ACCEPT_ONCE_VALUE: &str = "accept";const APPROVAL_ACCEPT_SESSION_VALUE: &str = "accept_session";const APPROVAL_ACCEPT_ALWAYS_VALUE: &str = "accept_always";const APPROVAL_DECLINE_VALUE: &str = "decline";const APPROVAL_CANCEL_VALUE: &str = "cancel";const APPROVAL_TOOL_PARAM_DISPLAY_LIMIT: usize = 3;const APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES: usize = 60;const TOOL_TYPE_KEY: &str = "tool_type";const TOOL_ID_KEY: &str = "tool_id";const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type";const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason";const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url";#[derive(Clone, PartialEq, Default)]struct ComposerDraft { text: String, text_elements: Vec<TextElement>, local_image_paths: Vec<PathBuf>, pending_pastes: Vec<(String, String)>,}impl ComposerDraft { fn text_with_pending(&self) -> String { if self.pending_pastes.is_empty() { return self.text.clone(); } debug_assert!( !self.text_elements.is_empty(), "pending pastes should always have matching text elements" ); let (expanded, _) = ChatComposer::expand_pending_pastes( &self.text, self.text_elements.clone(), &self.pending_pastes, ); expanded }}#[derive(Clone, Debug, PartialEq)]struct McpServerElicitationOption { label: String, description: Option<String>, value: Value,}#[derive(Clone, Debug, PartialEq)]enum McpServerElicitationFieldInput { Select { options: Vec<McpServerElicitationOption>, default_idx: Option<usize>, }, Text { secret: bool, },}#[derive(Clone, Debug, PartialEq)]struct McpServerElicitationField { id: String, label: String, prompt: String, required: bool, input: McpServerElicitationFieldInput,}#[derive(Clone, Debug, PartialEq, Eq)]enum McpServerElicitationResponseMode { FormContent, ApprovalAction,}#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum ToolSuggestionToolType { Connector, Plugin,}#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum ToolSuggestionType { Install, Enable,}#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct ToolSuggestionRequest { pub(crate) tool_type: ToolSuggestionToolType, pub(crate) suggest_type: ToolSuggestionType, pub(crate) suggest_reason: String, pub(crate) tool_id: String, pub(crate) tool_name: String, pub(crate) install_url: Option<String>,}#[derive(Clone, Debug, PartialEq)]struct McpToolApprovalDisplayParam { name: String, value: Value, display_name: String,}#[derive(Clone, Debug, PartialEq)]pub(crate) struct McpServerElicitationFormRequest { thread_id: ThreadId, server_name: String, request_id: AppServerRequestId, message: String, approval_display_params: Vec<McpToolApprovalDisplayParam>, response_mode: McpServerElicitationResponseMode, fields: Vec<McpServerElicitationField>, tool_suggestion: Option<ToolSuggestionRequest>,}#[derive(Default)]struct McpServerElicitationAnswerState { selection: ScrollState, draft: ComposerDraft, answer_committed: bool,}#[derive(Clone, Debug, PartialEq, Eq)]pub(crate) struct FooterTip { text: String, highlight: bool,}impl FooterTip { fn new(text: impl Into<String>) -> Self { Self { text: text.into(), highlight: false, } } fn highlighted(text: impl Into<String>) -> Self { Self { text: text.into(), highlight: true, } }}impl McpServerElicitationFormRequest { pub(crate) fn from_app_server_request( thread_id: ThreadId, request_id: AppServerRequestId, request: McpServerElicitationRequestParams, ) -> Option<Self> { let McpServerElicitationRequestParams { server_name, request, .. } = request; let McpServerElicitationRequest::Form { meta, message, requested_schema, } = request else { return None; }; let requested_schema = serde_json::to_value(requested_schema).ok()?; Self::from_parts( thread_id, server_name, request_id, meta, message, requested_schema, ) } fn from_parts( thread_id: ThreadId, server_name: String, request_id: AppServerRequestId, meta: Option<Value>, message: String, requested_schema: Value, ) -> Option<Self> { let tool_suggestion = parse_tool_suggestion_request(meta.as_ref()); let is_tool_approval = meta .as_ref() .and_then(Value::as_object) .and_then(|meta| meta.get(APPROVAL_META_KIND_KEY)) .and_then(Value::as_str) == Some(APPROVAL_META_KIND_MCP_TOOL_CALL); let is_empty_object_schema = requested_schema.as_object().is_some_and(|schema| { schema.get("type").and_then(Value::as_str) == Some("object") && schema .get("properties") .and_then(Value::as_object) .is_some_and(serde_json::Map::is_empty) }); let is_message_only_schema = requested_schema.is_null() || is_empty_object_schema; let is_tool_approval_action = is_tool_approval && is_message_only_schema; let approval_display_params = if is_tool_approval_action { parse_tool_approval_display_params(meta.as_ref()) } else { Vec::new() }; let (response_mode, fields) = if tool_suggestion.is_some() && is_message_only_schema { (McpServerElicitationResponseMode::FormContent, Vec::new()) } else if is_message_only_schema { let allow_description = if is_tool_approval_action { "Run the tool and continue." } else { "Allow this request and continue." }; let mut options = vec![McpServerElicitationOption { label: "Allow".to_string(), description: Some(allow_description.to_string()), value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), }]; if approval_supports_persist_mode(meta.as_ref(), APPROVAL_PERSIST_SESSION_VALUE) { let description = if is_tool_approval_action { "Run the tool and remember this choice for this session." } else { "Allow this request and remember this choice for this session." }; options.push(McpServerElicitationOption { label: "Allow for this session".to_string(), description: Some(description.to_string()), value: Value::String(APPROVAL_ACCEPT_SESSION_VALUE.to_string()), }); } if approval_supports_persist_mode(meta.as_ref(), APPROVAL_PERSIST_ALWAYS_VALUE) { let description = if is_tool_approval_action { "Run the tool and remember this choice for future tool calls." } else { "Allow this request and remember this choice for future requests." }; options.push(McpServerElicitationOption { label: "Always allow".to_string(), description: Some(description.to_string()), value: Value::String(APPROVAL_ACCEPT_ALWAYS_VALUE.to_string()), }); } if is_tool_approval_action { options.push(McpServerElicitationOption { label: "Cancel".to_string(), description: Some("Cancel this tool call".to_string()), value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), }); } else { options.extend([ McpServerElicitationOption { label: "Deny".to_string(), description: Some("Decline this request and continue.".to_string()), value: Value::String(APPROVAL_DECLINE_VALUE.to_string()), }, McpServerElicitationOption { label: "Cancel".to_string(), description: Some("Cancel this request".to_string()), value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), }, ]); } ( McpServerElicitationResponseMode::ApprovalAction, vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), label: String::new(), prompt: String::new(), required: true, input: McpServerElicitationFieldInput::Select { options, default_idx: Some(0), }, }], ) } else { ( McpServerElicitationResponseMode::FormContent, parse_fields_from_schema(&requested_schema)?, ) }; Some(Self { thread_id, server_name, request_id, message, approval_display_params, response_mode, fields, tool_suggestion, }) } pub(crate) fn tool_suggestion(&self) -> Option<&ToolSuggestionRequest> { self.tool_suggestion.as_ref() } pub(crate) fn thread_id(&self) -> ThreadId { self.thread_id } pub(crate) fn server_name(&self) -> &str { self.server_name.as_str() } pub(crate) fn request_id(&self) -> &AppServerRequestId { &self.request_id }}fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionRequest> { let meta = meta?.as_object()?; if meta.get(APPROVAL_META_KIND_KEY).and_then(Value::as_str) != Some(APPROVAL_META_KIND_TOOL_SUGGESTION) { return None; } let tool_type = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) { Some("connector") => ToolSuggestionToolType::Connector, Some("plugin") => ToolSuggestionToolType::Plugin, _ => return None, }; let suggest_type = match meta .get(TOOL_SUGGEST_SUGGEST_TYPE_KEY) .and_then(Value::as_str) { Some("install") => ToolSuggestionType::Install, Some("enable") => ToolSuggestionType::Enable, _ => return None, }; Some(ToolSuggestionRequest { tool_type, suggest_type, suggest_reason: meta .get(TOOL_SUGGEST_REASON_KEY) .and_then(Value::as_str)? .to_string(), tool_id: meta.get(TOOL_ID_KEY).and_then(Value::as_str)?.to_string(), tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(), install_url: meta .get(TOOL_SUGGEST_INSTALL_URL_KEY) .and_then(Value::as_str) .map(ToString::to_string), })}fn approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str) -> bool { let Some(persist) = meta .and_then(Value::as_object) .and_then(|meta| meta.get(APPROVAL_PERSIST_KEY)) else { return false; }; match persist { Value::String(value) => value == expected_mode, Value::Array(values) => values .iter() .filter_map(Value::as_str) .any(|value| value == expected_mode), _ => false, }}fn parse_tool_approval_display_params(meta: Option<&Value>) -> Vec<McpToolApprovalDisplayParam> { let Some(meta) = meta.and_then(Value::as_object) else { return Vec::new(); }; let display_params = meta .get(APPROVAL_TOOL_PARAMS_DISPLAY_KEY) .and_then(Value::as_array) .map(|display_params| { display_params .iter() .filter_map(parse_tool_approval_display_param) .collect::<Vec<_>>() }) .unwrap_or_default(); if !display_params.is_empty() { return display_params; } let mut fallback_params = meta .get(APPROVAL_TOOL_PARAMS_KEY) .and_then(Value::as_object) .map(|tool_params| { tool_params .iter() .map(|(name, value)| McpToolApprovalDisplayParam { name: name.clone(), value: value.clone(), display_name: name.clone(), }) .collect::<Vec<_>>() }) .unwrap_or_default(); fallback_params.sort_by(|left, right| left.name.cmp(&right.name)); fallback_params}fn parse_tool_approval_display_param(value: &Value) -> Option<McpToolApprovalDisplayParam> { let value = value.as_object()?; let name = value.get("name")?.as_str()?.trim(); if name.is_empty() { return None; } let display_name = value .get("display_name") .and_then(Value::as_str) .unwrap_or(name) .trim(); if display_name.is_empty() { return None; } Some(McpToolApprovalDisplayParam { name: name.to_string(), value: value.get("value")?.clone(), display_name: display_name.to_string(), })}fn format_tool_approval_display_message( message: &str, approval_display_params: &[McpToolApprovalDisplayParam],) -> String { let message = message.trim(); if approval_display_params.is_empty() { return message.to_string(); } let mut sections = Vec::new(); if !message.is_empty() { sections.push(message.to_string()); } let param_lines = approval_display_params .iter() .take(APPROVAL_TOOL_PARAM_DISPLAY_LIMIT) .map(format_tool_approval_display_param_line) .collect::<Vec<_>>(); if !param_lines.is_empty() { sections.push(param_lines.join("\n")); } let mut message = sections.join("\n\n"); message.push('\n'); message}fn format_tool_approval_display_param_line(param: &McpToolApprovalDisplayParam) -> String { format!( "{}: {}", param.display_name, format_tool_approval_display_param_value(¶m.value) )}fn format_tool_approval_display_param_value(value: &Value) -> String { let formatted = match value { Value::String(text) => text.split_whitespace().collect::<Vec<_>>().join(" "), _ => { let compact_json = value.to_string(); format_json_compact(&compact_json).unwrap_or(compact_json) } }; truncate_text(&formatted, APPROVAL_TOOL_PARAM_VALUE_TRUNCATE_GRAPHEMES)}fn parse_fields_from_schema(requested_schema: &Value) -> Option<Vec<McpServerElicitationField>> { let schema = requested_schema.as_object()?; if schema.get("type").and_then(Value::as_str) != Some("object") { return None; } let required = schema .get("required") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(ToString::to_string) .collect::<HashSet<_>>(); let properties = schema.get("properties")?.as_object()?; let mut fields = Vec::new(); for (id, property_schema) in properties { let property = serde_json::from_value::<McpElicitationPrimitiveSchema>(property_schema.clone()) .ok()?; fields.push(parse_field(id, property, required.contains(id))?); } if fields.is_empty() { return None; } Some(fields)}fn parse_field( id: &str, property: McpElicitationPrimitiveSchema, required: bool,) -> Option<McpServerElicitationField> { match property { McpElicitationPrimitiveSchema::String(schema) => { let label = schema.title.unwrap_or_else(|| id.to_string()); let prompt = schema.description.unwrap_or_else(|| label.clone()); Some(McpServerElicitationField { id: id.to_string(), label, prompt, required, input: McpServerElicitationFieldInput::Text { secret: false }, }) } McpElicitationPrimitiveSchema::Boolean(schema) => { let label = schema.title.unwrap_or_else(|| id.to_string()); let prompt = schema.description.unwrap_or_else(|| label.clone()); let default_idx = schema.default.map(|value| if value { 0 } else { 1 }); let options = [true, false] .into_iter() .map(|value| { let label = if value { "True" } else { "False" }.to_string(); McpServerElicitationOption { label, description: None, value: Value::Bool(value), } }) .collect(); Some(McpServerElicitationField { id: id.to_string(), label, prompt, required, input: McpServerElicitationFieldInput::Select { options, default_idx, }, }) } McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy(schema)) => { let label = schema.title.unwrap_or_else(|| id.to_string()); let prompt = schema.description.unwrap_or_else(|| label.clone()); let default_idx = schema .default .as_ref() .and_then(|value| schema.enum_.iter().position(|entry| entry == value)); let enum_names = schema.enum_names.unwrap_or_default(); let options = schema .enum_ .into_iter() .enumerate() .map(|(idx, value)| McpServerElicitationOption { label: enum_names .get(idx) .cloned() .unwrap_or_else(|| value.clone()), description: None, value: Value::String(value), }) .collect(); Some(McpServerElicitationField { id: id.to_string(), label, prompt, required, input: McpServerElicitationFieldInput::Select { options, default_idx, }, }) } McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::SingleSelect(schema)) => { parse_single_select_field(id, schema, required) } McpElicitationPrimitiveSchema::Number(_) | McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::MultiSelect(_)) => None, }}fn parse_single_select_field( id: &str, schema: McpElicitationSingleSelectEnumSchema, required: bool,) -> Option<McpServerElicitationField> { match schema { McpElicitationSingleSelectEnumSchema::Untitled(schema) => { let label = schema.title.unwrap_or_else(|| id.to_string()); let prompt = schema.description.unwrap_or_else(|| label.clone()); let default_idx = schema .default .as_ref() .and_then(|value| schema.enum_.iter().position(|entry| entry == value)); let options = schema .enum_ .into_iter() .map(|value| McpServerElicitationOption { label: value.clone(), description: None, value: Value::String(value), }) .collect(); Some(McpServerElicitationField { id: id.to_string(), label, prompt, required, input: McpServerElicitationFieldInput::Select { options, default_idx, }, }) } McpElicitationSingleSelectEnumSchema::Titled(schema) => { let label = schema.title.unwrap_or_else(|| id.to_string()); let prompt = schema.description.unwrap_or_else(|| label.clone()); let default_idx = schema.default.as_ref().and_then(|value| { schema .one_of .iter() .position(|entry| entry.const_.as_str() == value) }); let options = schema .one_of .into_iter() .map(|entry| McpServerElicitationOption { label: entry.title, description: None, value: Value::String(entry.const_), }) .collect(); Some(McpServerElicitationField { id: id.to_string(), label, prompt, required, input: McpServerElicitationFieldInput::Select { options, default_idx, }, }) } }}pub(crate) struct McpServerElicitationOverlay { app_event_tx: AppEventSender, request: McpServerElicitationFormRequest, queue: VecDeque<McpServerElicitationFormRequest>, composer: ChatComposer, answers: Vec<McpServerElicitationAnswerState>, current_idx: usize, done: bool, validation_error: Option<String>, list_keymap: ListKeymap,}impl McpServerElicitationOverlay { #[cfg(test)] pub(crate) fn new( request: McpServerElicitationFormRequest, app_event_tx: AppEventSender, has_input_focus: bool, enhanced_keys_supported: bool, disable_paste_burst: bool, ) -> Self { Self::new_with_keymap( request, app_event_tx, has_input_focus, enhanced_keys_supported, disable_paste_burst, crate::keymap::RuntimeKeymap::defaults().list, ) } pub(crate) fn new_with_keymap( request: McpServerElicitationFormRequest, app_event_tx: AppEventSender, has_input_focus: bool, enhanced_keys_supported: bool, disable_paste_burst: bool, list_keymap: ListKeymap, ) -> Self { let mut composer = ChatComposer::new_with_config( has_input_focus, app_event_tx.clone(), enhanced_keys_supported, ANSWER_PLACEHOLDER.to_string(), disable_paste_burst, ChatComposerConfig::plain_text(), ); composer.set_footer_hint_override(Some(Vec::new())); let mut overlay = Self { app_event_tx, request, queue: VecDeque::new(), composer, answers: Vec::new(), current_idx: 0, done: false, validation_error: None, list_keymap, }; overlay.reset_for_request(); overlay.restore_current_draft(); overlay } fn reset_for_request(&mut self) { self.answers = self .request .fields .iter() .map(|field| { let mut selection = ScrollState::new(); let (draft, answer_committed) = match &field.input { McpServerElicitationFieldInput::Select { default_idx, .. } => { selection.selected_idx = default_idx.or(Some(0)); (ComposerDraft::default(), default_idx.is_some()) } McpServerElicitationFieldInput::Text { .. } => { (ComposerDraft::default(), false) } }; McpServerElicitationAnswerState { selection, draft, answer_committed, } }) .collect(); self.current_idx = 0; self.validation_error = None; self.composer .set_text_content(String::new(), Vec::new(), Vec::new()); } fn field_count(&self) -> usize { self.request.fields.len() } fn current_index(&self) -> usize { self.current_idx } fn current_field(&self) -> Option<&McpServerElicitationField> { self.request.fields.get(self.current_index()) } fn current_answer(&self) -> Option<&McpServerElicitationAnswerState> { self.answers.get(self.current_index()) } fn current_answer_mut(&mut self) -> Option<&mut McpServerElicitationAnswerState> { let idx = self.current_idx; self.answers.get_mut(idx) } fn capture_composer_draft(&self) -> ComposerDraft { ComposerDraft { text: self.composer.current_text(), text_elements: self.composer.text_elements(), local_image_paths: self .composer .local_images() .into_iter() .map(|img| img.path) .collect(), pending_pastes: self.composer.pending_pastes(), } } fn restore_current_draft(&mut self) { self.composer .set_placeholder_text(self.answer_placeholder().to_string()); self.composer.set_footer_hint_override(Some(Vec::new())); if self.current_field_is_select() { self.composer .set_text_content(String::new(), Vec::new(), Vec::new()); self.composer.move_cursor_to_end(); return; } let Some(answer) = self.current_answer() else { self.composer .set_text_content(String::new(), Vec::new(), Vec::new()); self.composer.move_cursor_to_end(); return; }; let draft = answer.draft.clone(); self.composer .set_text_content(draft.text, draft.text_elements, draft.local_image_paths); self.composer.set_pending_pastes(draft.pending_pastes); self.composer.move_cursor_to_end(); } fn save_current_draft(&mut self) { if self.current_field_is_select() { return; } let draft = self.capture_composer_draft(); if let Some(answer) = self.current_answer_mut() { if answer.answer_committed && answer.draft != draft { answer.answer_committed = false; } answer.draft = draft; } } fn clear_current_draft(&mut self) { if self.current_field_is_select() { return; } if let Some(answer) = self.current_answer_mut() { answer.draft = ComposerDraft::default(); answer.answer_committed = false; } self.composer .set_text_content(String::new(), Vec::new(), Vec::new()); self.composer.move_cursor_to_end(); } fn answer_placeholder(&self) -> &'static str { self.current_field().map_or(ANSWER_PLACEHOLDER, |field| { if field.required { ANSWER_PLACEHOLDER } else { OPTIONAL_ANSWER_PLACEHOLDER } }) } fn current_field_is_select(&self) -> bool { matches!( self.current_field().map(|field| &field.input), Some(McpServerElicitationFieldInput::Select { .. }) ) } fn current_field_is_secret(&self) -> bool { matches!( self.current_field().map(|field| &field.input), Some(McpServerElicitationFieldInput::Text { secret: true }) ) } fn selected_option_index(&self) -> Option<usize> { self.current_answer() .and_then(|answer| answer.selection.selected_idx) } fn options_len(&self) -> usize { self.current_options().len() } fn current_options(&self) -> &[McpServerElicitationOption] { match self.current_field().map(|field| &field.input) { Some(McpServerElicitationFieldInput::Select { options, .. }) => options.as_slice(), _ => &[], } } fn option_rows(&self) -> Vec<GenericDisplayRow> { let selected_idx = self.selected_option_index(); self.current_options() .iter() .enumerate() .map(|(idx, option)| { let prefix = if selected_idx.is_some_and(|selected| selected == idx) { '›' } else { ' ' }; let number = idx + 1; let prefix_label = format!("{prefix} {number}. "); let wrap_indent = UnicodeWidthStr::width(prefix_label.as_str()); GenericDisplayRow { name: format!("{prefix_label}{}", option.label), description: option.description.clone(), wrap_indent: Some(wrap_indent), ..Default::default() } }) .collect() } fn wrapped_prompt_lines(&self, width: u16) -> Vec<String> { textwrap::wrap(&self.current_prompt_text(), width.max(1) as usize) .into_iter() .map(|line| line.to_string()) .collect() } fn current_prompt_text(&self) -> String { let request_message = format_tool_approval_display_message( &self.request.message, &self.request.approval_display_params, ); let Some(field) = self.current_field() else { return request_message; }; let mut sections = Vec::new(); if !request_message.trim().is_empty() { sections.push(request_message); } let field_prompt = if field.label.trim().is_empty() || field.prompt.trim().is_empty() || field.label == field.prompt { if field.prompt.trim().is_empty() { field.label.clone() } else { field.prompt.clone() } } else { format!("{}\n{}", field.label, field.prompt) }; if !field_prompt.trim().is_empty() { sections.push(field_prompt); } sections.join("\n\n") } fn footer_tips(&self) -> Vec<FooterTip> { let mut tips = Vec::new(); let is_last_field = self.current_index().saturating_add(1) >= self.field_count(); if self.current_field_is_select() { if self.field_count() == 1 { tips.push(FooterTip::highlighted("enter to submit")); } else if is_last_field { tips.push(FooterTip::highlighted("enter to submit all")); } else { tips.push(FooterTip::new("enter to submit answer")); } } else if self.field_count() == 1 { tips.push(FooterTip::highlighted("enter to submit")); } else if is_last_field { tips.push(FooterTip::highlighted("enter to submit all")); } else { tips.push(FooterTip::new("enter to submit answer")); } if self.field_count() > 1 { if self.current_field_is_select() { tips.push(FooterTip::new("←/→ to navigate fields")); } else { tips.push(FooterTip::new("ctrl + p / ctrl + n change field")); } } tips.push(FooterTip::new("esc to cancel")); tips } fn footer_tip_lines(&self, width: u16) -> Vec<Vec<FooterTip>> { let mut tips = Vec::new(); if let Some(error) = self.validation_error.as_ref() { tips.push(FooterTip::highlighted(error.clone())); } tips.extend(self.footer_tips()); wrap_footer_tips(width, tips) } fn options_required_height(&self, width: u16) -> u16 { let rows = self.option_rows(); if rows.is_empty() { return 0; } let mut state = self .current_answer() .map(|answer| answer.selection) .unwrap_or_default(); if state.selected_idx.is_none() { state.selected_idx = Some(0); } measure_rows_height(&rows, &state, rows.len(), width.max(1)) } fn input_height(&self, width: u16) -> u16 { if self.current_field_is_select() { return self.options_required_height(width); } self.composer .desired_height(width.max(1)) .clamp(MIN_COMPOSER_HEIGHT, MIN_COMPOSER_HEIGHT.saturating_add(5)) } fn move_field(&mut self, next: bool) { let len = self.field_count(); if len == 0 { return; } self.save_current_draft(); let offset = if next { 1 } else { len.saturating_sub(1) }; self.current_idx = (self.current_idx + offset) % len; self.validation_error = None; self.restore_current_draft(); } fn jump_to_field(&mut self, idx: usize) { if idx >= self.field_count() { return; } self.save_current_draft(); self.current_idx = idx; self.restore_current_draft(); } fn field_value(&self, idx: usize) -> Option<Value> { let field = self.request.fields.get(idx)?; let answer = self.answers.get(idx)?; match &field.input { McpServerElicitationFieldInput::Select { options, .. } => { if !answer.answer_committed { return None; } let selected_idx = answer.selection.selected_idx?; options.get(selected_idx).map(|option| option.value.clone()) } McpServerElicitationFieldInput::Text { .. } => { if !answer.answer_committed { return None; } let text = answer.draft.text_with_pending(); let text = text.trim(); (!text.is_empty()).then(|| Value::String(text.to_string())) } } } fn required_unanswered_count(&self) -> usize { self.request .fields .iter() .enumerate() .filter(|(idx, field)| field.required && self.field_value(*idx).is_none()) .count() } fn first_required_unanswered_index(&self) -> Option<usize> { self.request .fields .iter() .enumerate() .find(|(idx, field)| field.required && self.field_value(*idx).is_none()) .map(|(idx, _)| idx) } fn is_current_field_answered(&self) -> bool { self.field_value(self.current_index()).is_some() } fn option_index_for_digit(&self, ch: char) -> Option<usize> { let digit = ch.to_digit(10)?; if digit == 0 { return None; } let idx = (digit - 1) as usize; (idx < self.options_len()).then_some(idx) } fn select_current_option(&mut self, committed: bool) { let options_len = self.options_len(); if let Some(answer) = self.current_answer_mut() { answer.selection.clamp_selection(options_len); answer.answer_committed = committed; } } fn clear_selection(&mut self) { if let Some(answer) = self.current_answer_mut() { answer.selection.reset(); answer.answer_committed = false; } } fn dispatch_cancel(&self) { self.app_event_tx.resolve_elicitation( self.request.thread_id, self.request.server_name.clone(), self.request.request_id.clone(), McpServerElicitationAction::Cancel, /*content*/ None, /*meta*/ None, ); } fn advance_queue_or_complete(&mut self) { if let Some(next) = self.queue.pop_front() { self.request = next; self.reset_for_request(); self.restore_current_draft(); } else { self.done = true; } } fn submit_answers(&mut self) { self.save_current_draft(); if let Some(idx) = self.first_required_unanswered_index() { self.validation_error = Some("Answer required fields before submitting.".to_string()); self.jump_to_field(idx); return; } self.validation_error = None; if self.request.response_mode == McpServerElicitationResponseMode::ApprovalAction { let (decision, meta) = match self.field_value(/*idx*/ 0).as_ref().and_then(Value::as_str) { Some(APPROVAL_ACCEPT_ONCE_VALUE) => (McpServerElicitationAction::Accept, None), Some(APPROVAL_ACCEPT_SESSION_VALUE) => ( McpServerElicitationAction::Accept, Some(serde_json::json!({ APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE, })), ), Some(APPROVAL_ACCEPT_ALWAYS_VALUE) => ( McpServerElicitationAction::Accept, Some(serde_json::json!({ APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_ALWAYS_VALUE, })), ), Some(APPROVAL_DECLINE_VALUE) => (McpServerElicitationAction::Decline, None), Some(APPROVAL_CANCEL_VALUE) => (McpServerElicitationAction::Cancel, None), _ => (McpServerElicitationAction::Cancel, None), }; self.app_event_tx.resolve_elicitation( self.request.thread_id, self.request.server_name.clone(), self.request.request_id.clone(), decision, /*content*/ None, meta, ); self.advance_queue_or_complete(); return; } let content = self .request .fields .iter() .enumerate() .filter_map(|(idx, field)| self.field_value(idx).map(|value| (field.id.clone(), value))) .collect::<serde_json::Map<_, _>>(); self.app_event_tx.resolve_elicitation( self.request.thread_id, self.request.server_name.clone(), self.request.request_id.clone(), McpServerElicitationAction::Accept, Some(Value::Object(content)), /*meta*/ None, ); self.advance_queue_or_complete(); } fn dismiss_resolved_request(&mut self, request: &ResolvedAppServerRequest) -> bool { let ResolvedAppServerRequest::McpElicitation { server_name, request_id, } = request else { return false; }; let queue_len = self.queue.len(); self.queue.retain(|queued_request| { queued_request.server_name != *server_name || queued_request.request_id != *request_id }); if self.request.server_name == *server_name && self.request.request_id == *request_id { self.advance_queue_or_complete(); return true; } self.queue.len() != queue_len } fn go_next_or_submit(&mut self) { if self.current_index() + 1 >= self.field_count() { self.submit_answers(); } else { self.move_field(/*next*/ true); } } fn apply_submission_to_draft(&mut self, text: String, text_elements: Vec<TextElement>) { let local_image_paths = self .composer .local_images() .into_iter() .map(|img| img.path) .collect::<Vec<_>>(); if let Some(answer) = self.current_answer_mut() { answer.draft = ComposerDraft { text: text.clone(), text_elements: text_elements.clone(), local_image_paths: local_image_paths.clone(), pending_pastes: Vec::new(), }; answer.answer_committed = !text.trim().is_empty(); } self.composer .set_text_content(text, text_elements, local_image_paths); self.composer.move_cursor_to_end(); self.composer.set_footer_hint_override(Some(Vec::new())); } fn handle_composer_input_result(&mut self, result: InputResult) -> bool { match result { InputResult::Submitted { text, text_elements, } | InputResult::Queued { text, text_elements, .. } => { self.apply_submission_to_draft(text, text_elements); self.validation_error = None; self.go_next_or_submit(); true } _ => false, } } fn render_prompt(&self, area: Rect, buf: &mut Buffer) { if area.width == 0 || area.height == 0 { return; } let answered = self.is_current_field_answered(); for (offset, line) in self.wrapped_prompt_lines(area.width).iter().enumerate() { let y = area.y.saturating_add(offset as u16); if y >= area.y + area.height { break; } let line = if answered { Line::from(line.clone()) } else { Line::from(line.clone()).cyan() }; Paragraph::new(line).render( Rect { x: area.x, y, width: area.width, height: 1, }, buf, ); } } fn render_input(&self, area: Rect, buf: &mut Buffer) { if area.width == 0 || area.height == 0 { return; } if self.current_field_is_select() { let rows = self.option_rows(); let mut state = self .current_answer() .map(|answer| answer.selection) .unwrap_or_default(); if state.selected_idx.is_none() && !rows.is_empty() { state.selected_idx = Some(0); } state.ensure_visible(rows.len(), area.height as usize); render_rows(area, buf, &rows, &state, rows.len().max(1), "No options"); return; } if self.current_field_is_secret() { self.composer.render_with_mask(area, buf, Some('*')); } else { self.composer.render(area, buf); } } fn render_footer(&self, area: Rect, input_area_height: u16, buf: &mut Buffer) { if area.width == 0 || area.height == 0 { return; } let options_hidden = self.current_field_is_select() && input_area_height > 0 && self.options_required_height(area.width) > input_area_height; let option_tip = if options_hidden { let selected = self.selected_option_index().unwrap_or(0).saturating_add(1); let total = self.options_len(); Some(FooterTip::new(format!("option {selected}/{total}"))) } else { None }; let mut tip_lines = self.footer_tip_lines(area.width); if let Some(prefix) = option_tip { let mut tips = vec![prefix]; if let Some(first_line) = tip_lines.first_mut() { let mut first = Vec::new(); std::mem::swap(first_line, &mut first); tips.extend(first); *first_line = tips; } else { tip_lines.push(tips); } } for (row_idx, tips) in tip_lines.into_iter().take(area.height as usize).enumerate() { let mut spans = Vec::new(); for (tip_idx, tip) in tips.into_iter().enumerate() { if tip_idx > 0 { spans.push(FOOTER_SEPARATOR.into()); } if tip.highlight { spans.push(tip.text.cyan().bold().not_dim()); } else { spans.push(tip.text.into()); } } let line = Line::from(spans).dim(); Paragraph::new(line).render( Rect { x: area.x, y: area.y.saturating_add(row_idx as u16), width: area.width, height: 1, }, buf, ); } }}impl Renderable for McpServerElicitationOverlay { fn desired_height(&self, width: u16) -> u16 { let outer = Rect::new(0, 0, width, u16::MAX); let inner = menu_surface_inset(outer); let inner_width = inner.width.max(1); let height = 1u16 .saturating_add(self.wrapped_prompt_lines(inner_width).len() as u16) .saturating_add(self.input_height(inner_width)) .saturating_add(self.footer_tip_lines(inner_width).len() as u16) .saturating_add(menu_surface_padding_height()); height.max(MIN_OVERLAY_HEIGHT) } fn render(&self, area: Rect, buf: &mut Buffer) { if area.width == 0 || area.height == 0 { return; } let content_area = render_menu_surface(area, buf); if content_area.width == 0 || content_area.height == 0 { return; } let prompt_lines = self.wrapped_prompt_lines(content_area.width); let footer_lines = self.footer_tip_lines(content_area.width); let mut remaining = content_area.height; let progress_height = u16::from(remaining > 0); remaining = remaining.saturating_sub(progress_height); let footer_height = (footer_lines.len() as u16).min(remaining.saturating_sub(1)); remaining = remaining.saturating_sub(footer_height); let min_input_height = if self.current_field_is_select() { u16::from(remaining > 0) } else { MIN_COMPOSER_HEIGHT.min(remaining) }; let mut input_height = min_input_height; remaining = remaining.saturating_sub(input_height); let prompt_height = (prompt_lines.len() as u16).min(remaining); remaining = remaining.saturating_sub(prompt_height); input_height = input_height.saturating_add(remaining); let progress_area = Rect { x: content_area.x, y: content_area.y, width: content_area.width, height: progress_height, }; let prompt_area = Rect { x: content_area.x, y: progress_area.y.saturating_add(progress_area.height), width: content_area.width, height: prompt_height, }; let input_area = Rect { x: content_area.x, y: prompt_area.y.saturating_add(prompt_area.height), width: content_area.width, height: input_height, }; let footer_area = Rect { x: content_area.x, y: input_area.y.saturating_add(input_area.height), width: content_area.width, height: footer_height, }; let unanswered = self.required_unanswered_count(); let progress_line = if self.field_count() > 0 { let idx = self.current_index() + 1; let total = self.field_count(); let base = format!("Field {idx}/{total}"); if unanswered > 0 { Line::from(format!("{base} ({unanswered} required unanswered)").dim()) } else { Line::from(base.dim()) } } else { Line::from("No fields".dim()) }; Paragraph::new(progress_line).render(progress_area, buf); self.render_prompt(prompt_area, buf); self.render_input(input_area, buf); self.render_footer(footer_area, input_area.height, buf); } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { if self.current_field_is_select() { return None; } let content_area = menu_surface_inset(area); if content_area.width == 0 || content_area.height == 0 { return None; } let prompt_lines = self.wrapped_prompt_lines(content_area.width); let footer_lines = self.footer_tip_lines(content_area.width); let mut remaining = content_area.height; remaining = remaining.saturating_sub(u16::from(remaining > 0)); let footer_height = (footer_lines.len() as u16).min(remaining.saturating_sub(1)); remaining = remaining.saturating_sub(footer_height); let min_input_height = MIN_COMPOSER_HEIGHT.min(remaining); let mut input_height = min_input_height; remaining = remaining.saturating_sub(input_height); let prompt_height = (prompt_lines.len() as u16).min(remaining); remaining = remaining.saturating_sub(prompt_height); input_height = input_height.saturating_add(remaining); let input_area = Rect { x: content_area.x, y: content_area .y .saturating_add(1) .saturating_add(prompt_height), width: content_area.width, height: input_height, }; self.composer.cursor_pos(input_area) }}impl BottomPaneView for McpServerElicitationOverlay { fn prefer_esc_to_handle_key_event(&self) -> bool { true } fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { return; } if matches!(key_event.code, KeyCode::Esc) { self.dispatch_cancel(); self.done = true; return; } match key_event { KeyEvent { code: KeyCode::Char('p'), modifiers: KeyModifiers::CONTROL, .. } | KeyEvent { code: KeyCode::PageUp, modifiers: KeyModifiers::NONE, .. } => { self.move_field(/*next*/ false); return; } KeyEvent { code: KeyCode::Char('n'), modifiers: KeyModifiers::CONTROL, .. } | KeyEvent { code: KeyCode::PageDown, modifiers: KeyModifiers::NONE, .. } => { self.move_field(/*next*/ true); return; } KeyEvent { code: KeyCode::Left, modifiers: KeyModifiers::NONE, .. } | KeyEvent { code: KeyCode::Char('h'), modifiers: KeyModifiers::NONE, .. } if self.current_field_is_select() => { self.move_field(/*next*/ false); return; } _ if self.current_field_is_select() && self.list_keymap.move_left.is_pressed(key_event) => { self.move_field(/*next*/ false); return; } KeyEvent { code: KeyCode::Right, modifiers: KeyModifiers::NONE, .. } | KeyEvent { code: KeyCode::Char('l'), modifiers: KeyModifiers::NONE, .. } if self.current_field_is_select() => { self.move_field(/*next*/ true); return; } _ if self.current_field_is_select() && self.list_keymap.move_right.is_pressed(key_event) => { self.move_field(/*next*/ true); return; } _ => {} } if self.current_field_is_select() { self.validation_error = None; let options_len = self.options_len(); match key_event.code { KeyCode::Up | KeyCode::Char('k') => { if let Some(answer) = self.current_answer_mut() { answer.selection.move_up_wrap(options_len); answer.answer_committed = false; } } KeyCode::Down | KeyCode::Char('j') => { if let Some(answer) = self.current_answer_mut() { answer.selection.move_down_wrap(options_len); answer.answer_committed = false; } } KeyCode::Backspace | KeyCode::Delete => self.clear_selection(), KeyCode::Char(' ') => self.select_current_option(/*committed*/ true), KeyCode::Enter => { if self.selected_option_index().is_some() { self.select_current_option(/*committed*/ true); } self.go_next_or_submit(); } KeyCode::Char(ch) => { if let Some(option_idx) = self.option_index_for_digit(ch) { if let Some(answer) = self.current_answer_mut() { answer.selection.selected_idx = Some(option_idx); } self.select_current_option(/*committed*/ true); self.go_next_or_submit(); } } _ => {} } return; } let before = self.capture_composer_draft(); let (result, _) = self.composer.handle_key_event(key_event); let submitted = self.handle_composer_input_result(result); if submitted { return; } let after = self.capture_composer_draft(); if before != after { self.validation_error = None; if let Some(answer) = self.current_answer_mut() { answer.answer_committed = false; } } } fn terminal_title_requires_action(&self) -> bool { true } fn on_ctrl_c(&mut self) -> CancellationEvent { if !self.current_field_is_select() && !self.composer.current_text_with_pending().is_empty() { self.clear_current_draft(); return CancellationEvent::Handled; } self.dispatch_cancel(); self.done = true; CancellationEvent::Handled } fn is_complete(&self) -> bool { self.done } fn handle_paste(&mut self, pasted: String) -> bool { if pasted.is_empty() || self.current_field_is_select() { return false; } self.validation_error = None; if let Some(answer) = self.current_answer_mut() { answer.answer_committed = false; } self.composer.handle_paste(pasted) } fn flush_paste_burst_if_due(&mut self) -> bool { self.composer.flush_paste_burst_if_due() } fn is_in_paste_burst(&self) -> bool { self.composer.is_in_paste_burst() } fn try_consume_mcp_server_elicitation_request( &mut self, request: McpServerElicitationFormRequest, ) -> Option<McpServerElicitationFormRequest> { self.queue.push_back(request); None } fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool { self.dismiss_resolved_request(request) }}fn wrap_footer_tips(width: u16, tips: Vec<FooterTip>) -> Vec<Vec<FooterTip>> { let max_width = width.max(1) as usize; let separator_width = UnicodeWidthStr::width(FOOTER_SEPARATOR); if tips.is_empty() { return vec![Vec::new()]; } let mut lines = Vec::new(); let mut current = Vec::new(); let mut used = 0usize; for tip in tips { let tip_width = UnicodeWidthStr::width(tip.text.as_str()).min(max_width); let extra = if current.is_empty() { tip_width } else { separator_width.saturating_add(tip_width) }; if !current.is_empty() && used.saturating_add(extra) > max_width { lines.push(current); current = Vec::new(); used = 0; } if current.is_empty() { used = tip_width; } else { used = used .saturating_add(separator_width) .saturating_add(tip_width); } current.push(tip); } if current.is_empty() { lines.push(Vec::new()); } else { lines.push(current); } lines}#[cfg(test)]mod tests { use super::*; use crate::app_event::AppEvent; use crate::render::renderable::Renderable; use pretty_assertions::assert_eq; use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::mpsc::unbounded_channel; fn test_sender() -> (AppEventSender, UnboundedReceiver<AppEvent>) { let (tx_raw, rx) = unbounded_channel::<AppEvent>(); (AppEventSender::new(tx_raw), rx) } fn form_request( message: &str, requested_schema: Value, meta: Option<Value>, ) -> McpServerElicitationRequestParams { McpServerElicitationRequestParams { thread_id: "thread-1".to_string(), turn_id: Some("turn-1".to_string()), server_name: "server-1".to_string(), request: McpServerElicitationRequest::Form { meta, message: message.to_string(), requested_schema: serde_json::from_value(requested_schema) .expect("test schema should deserialize"), }, } } fn request_id(value: &str) -> AppServerRequestId { AppServerRequestId::String(value.to_string()) } fn from_form_request( thread_id: ThreadId, request: McpServerElicitationRequestParams, ) -> Option<McpServerElicitationFormRequest> { McpServerElicitationFormRequest::from_app_server_request( thread_id, request_id("request-1"), request, ) } fn empty_object_schema() -> Value { serde_json::json!({ "type": "object", "properties": {}, }) } fn tool_approval_meta( persist_modes: &[&str], tool_params: Option<Value>, tool_params_display: Option<Vec<(&str, Value, &str)>>, ) -> Option<Value> { let mut meta = serde_json::Map::from_iter([( APPROVAL_META_KIND_KEY.to_string(), Value::String(APPROVAL_META_KIND_MCP_TOOL_CALL.to_string()), )]); if !persist_modes.is_empty() { meta.insert( APPROVAL_PERSIST_KEY.to_string(), Value::Array( persist_modes .iter() .map(|mode| Value::String((*mode).to_string())) .collect(), ), ); } if let Some(tool_params) = tool_params { meta.insert(APPROVAL_TOOL_PARAMS_KEY.to_string(), tool_params); } if let Some(tool_params_display) = tool_params_display { meta.insert( APPROVAL_TOOL_PARAMS_DISPLAY_KEY.to_string(), Value::Array( tool_params_display .into_iter() .map(|(name, value, display_name)| { serde_json::json!({ "name": name, "value": value, "display_name": display_name, }) }) .collect(), ), ); } Some(Value::Object(meta)) } fn snapshot_buffer(buf: &Buffer) -> String { let mut lines = Vec::new(); for y in 0..buf.area().height { let mut row = String::new(); for x in 0..buf.area().width { row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); } lines.push(row); } lines.join("\n") } fn render_snapshot(overlay: &McpServerElicitationOverlay, area: Rect) -> String { let mut buf = Buffer::empty(area); overlay.render(area, &mut buf); snapshot_buffer(&buf) } #[test] fn parses_boolean_form_request() { let thread_id = ThreadId::default(); let request = from_form_request( thread_id, form_request( "Allow this request?", serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean", "title": "Confirm", "description": "Approve the pending action.", } }, "required": ["confirmed"], }), /*meta*/ None, ), ) .expect("expected supported form"); assert_eq!( request, McpServerElicitationFormRequest { thread_id, server_name: "server-1".to_string(), request_id: request_id("request-1"), message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::FormContent, fields: vec![McpServerElicitationField { id: "confirmed".to_string(), label: "Confirm".to_string(), prompt: "Approve the pending action.".to_string(), required: true, input: McpServerElicitationFieldInput::Select { options: vec![ McpServerElicitationOption { label: "True".to_string(), description: None, value: Value::Bool(true), }, McpServerElicitationOption { label: "False".to_string(), description: None, value: Value::Bool(false), }, ], default_idx: None, }, }], tool_suggestion: None, } ); } #[test] fn unsupported_numeric_form_falls_back() { let request = from_form_request( ThreadId::default(), form_request( "Pick a number", serde_json::json!({ "type": "object", "properties": { "count": { "type": "integer", "title": "Count", } }, }), /*meta*/ None, ), ); assert_eq!(request, None); } #[test] fn empty_object_schema_uses_approval_actions() { let thread_id = ThreadId::default(); let request = from_form_request( thread_id, form_request( "Allow this request?", empty_object_schema(), /*meta*/ None, ), ) .expect("expected approval fallback"); assert_eq!( request, McpServerElicitationFormRequest { thread_id, server_name: "server-1".to_string(), request_id: request_id("request-1"), message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, fields: vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), label: String::new(), prompt: String::new(), required: true, input: McpServerElicitationFieldInput::Select { options: vec![ McpServerElicitationOption { label: "Allow".to_string(), description: Some("Allow this request and continue.".to_string()), value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), }, McpServerElicitationOption { label: "Deny".to_string(), description: Some("Decline this request and continue.".to_string()), value: Value::String(APPROVAL_DECLINE_VALUE.to_string()), }, McpServerElicitationOption { label: "Cancel".to_string(), description: Some("Cancel this request".to_string()), value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), }, ], default_idx: Some(0), }, }], tool_suggestion: None, } ); } #[test] fn empty_tool_approval_schema_uses_approval_actions() { let thread_id = ThreadId::default(); let request = from_form_request( thread_id, form_request( "Allow this request?", empty_object_schema(), tool_approval_meta( &[], /*tool_params*/ None, /*tool_params_display*/ None, ), ), ) .expect("expected approval fallback"); assert_eq!( request, McpServerElicitationFormRequest { thread_id, server_name: "server-1".to_string(), request_id: request_id("request-1"), message: "Allow this request?".to_string(), approval_display_params: Vec::new(), response_mode: McpServerElicitationResponseMode::ApprovalAction, fields: vec![McpServerElicitationField { id: APPROVAL_FIELD_ID.to_string(), label: String::new(), prompt: String::new(), required: true, input: McpServerElicitationFieldInput::Select { options: vec![ McpServerElicitationOption { label: "Allow".to_string(), description: Some("Run the tool and continue.".to_string()), value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()), }, McpServerElicitationOption { label: "Cancel".to_string(), description: Some("Cancel this tool call".to_string()), value: Value::String(APPROVAL_CANCEL_VALUE.to_string()), }, ], default_idx: Some(0), }, }], tool_suggestion: None, } ); } #[test] fn tool_suggestion_meta_is_parsed_into_request_payload() { let request = from_form_request( ThreadId::default(), form_request( "Suggest Google Calendar", empty_object_schema(), Some(serde_json::json!({ "codex_approval_kind": "tool_suggestion", "tool_type": "connector", "suggest_type": "install", "suggest_reason": "Plan and reference events from your calendar", "tool_id": "connector_2128aebfecb84f64a069897515042a44", "tool_name": "Google Calendar", "install_url": "https://example.test/google-calendar", })), ), ) .expect("expected tool suggestion form"); assert_eq!( request.tool_suggestion(), Some(&ToolSuggestionRequest { tool_type: ToolSuggestionToolType::Connector, suggest_type: ToolSuggestionType::Install, suggest_reason: "Plan and reference events from your calendar".to_string(), tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(), tool_name: "Google Calendar".to_string(), install_url: Some("https://example.test/google-calendar".to_string()), }) ); } #[test] fn plugin_tool_suggestion_meta_without_install_url_is_parsed_into_request_payload() { let request = from_form_request( ThreadId::default(), form_request( "Suggest Slack", empty_object_schema(), Some(serde_json::json!({ "codex_approval_kind": "tool_suggestion", "tool_type": "plugin", "suggest_type": "install", "suggest_reason": "Install the Slack plugin to search messages", "tool_id": "slack@openai-curated", "tool_name": "Slack", })), ), ) .expect("expected tool suggestion form"); assert_eq!( request.tool_suggestion(), Some(&ToolSuggestionRequest { tool_type: ToolSuggestionToolType::Plugin, suggest_type: ToolSuggestionType::Install, suggest_reason: "Install the Slack plugin to search messages".to_string(), tool_id: "slack@openai-curated".to_string(), tool_name: "Slack".to_string(), install_url: None, }) ); } #[test] fn tool_approval_display_params_prefer_explicit_display_order() { let request = from_form_request( ThreadId::default(), form_request( "Allow Calendar to create an event", empty_object_schema(), tool_approval_meta( &[], Some(serde_json::json!({ "zeta": 3, "alpha": 1, })), Some(vec![ ( "calendar_id", Value::String("primary".to_string()), "Calendar", ), ( "title", Value::String("Roadmap review".to_string()), "Title", ), ]), ), ), ) .expect("expected approval fallback"); assert_eq!( request.approval_display_params, vec![ McpToolApprovalDisplayParam { name: "calendar_id".to_string(), value: Value::String("primary".to_string()), display_name: "Calendar".to_string(), }, McpToolApprovalDisplayParam { name: "title".to_string(), value: Value::String("Roadmap review".to_string()), display_name: "Title".to_string(), }, ] ); } #[test] fn submit_sends_accept_with_typed_content() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); let request = from_form_request( thread_id, form_request( "Allow this request?", serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean", "title": "Confirm", "description": "Approve the pending action.", } }, "required": ["confirmed"], }), /*meta*/ None, ), ) .expect("expected supported form"); let mut overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); overlay.select_current_option(/*committed*/ true); overlay.submit_answers(); let event = rx.try_recv().expect("expected resolution"); let AppEvent::SubmitThreadOp { thread_id: resolved_thread_id, op, } = event else { panic!("expected SubmitThreadOp"); }; assert_eq!(resolved_thread_id, thread_id); assert_eq!( op, Op::ResolveElicitation { server_name: "server-1".to_string(), request_id: request_id("request-1"), decision: McpServerElicitationAction::Accept, content: Some(serde_json::json!({ "confirmed": true, })), meta: None, } ); } #[test] fn horizontal_list_keys_move_between_select_fields() { let (tx, _rx) = test_sender(); let request = from_form_request( ThreadId::default(), form_request( "Choose values", serde_json::json!({ "type": "object", "properties": { "first": { "type": "boolean", "title": "First", }, "second": { "type": "boolean", "title": "Second", } }, "required": ["first", "second"], }), /*meta*/ None, ), ) .expect("expected supported form"); let mut overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); assert_eq!(overlay.current_idx, 0); overlay.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)); assert_eq!(overlay.current_idx, 1); overlay.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL)); assert_eq!(overlay.current_idx, 0); } #[test] fn empty_tool_approval_schema_session_choice_sets_persist_meta() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); let request = from_form_request( thread_id, form_request( "Allow this request?", empty_object_schema(), tool_approval_meta( &[ APPROVAL_PERSIST_SESSION_VALUE, APPROVAL_PERSIST_ALWAYS_VALUE, ], /*tool_params*/ None, /*tool_params_display*/ None, ), ), ) .expect("expected approval fallback"); let mut overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); if let Some(answer) = overlay.current_answer_mut() { answer.selection.selected_idx = Some(1); } overlay.select_current_option(/*committed*/ true); overlay.submit_answers(); let event = rx.try_recv().expect("expected resolution"); let AppEvent::SubmitThreadOp { thread_id: resolved_thread_id, op, } = event else { panic!("expected SubmitThreadOp"); }; assert_eq!(resolved_thread_id, thread_id); assert_eq!( op, Op::ResolveElicitation { server_name: "server-1".to_string(), request_id: request_id("request-1"), decision: McpServerElicitationAction::Accept, content: None, meta: Some(serde_json::json!({ APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_SESSION_VALUE, })), } ); } #[test] fn empty_tool_approval_schema_always_allow_sets_persist_meta() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); let request = from_form_request( thread_id, form_request( "Allow this request?", empty_object_schema(), tool_approval_meta( &[ APPROVAL_PERSIST_SESSION_VALUE, APPROVAL_PERSIST_ALWAYS_VALUE, ], /*tool_params*/ None, /*tool_params_display*/ None, ), ), ) .expect("expected approval fallback"); let mut overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); if let Some(answer) = overlay.current_answer_mut() { answer.selection.selected_idx = Some(2); } overlay.select_current_option(/*committed*/ true); overlay.submit_answers(); let event = rx.try_recv().expect("expected resolution"); let AppEvent::SubmitThreadOp { thread_id: resolved_thread_id, op, } = event else { panic!("expected SubmitThreadOp"); }; assert_eq!(resolved_thread_id, thread_id); assert_eq!( op, Op::ResolveElicitation { server_name: "server-1".to_string(), request_id: request_id("request-1"), decision: McpServerElicitationAction::Accept, content: None, meta: Some(serde_json::json!({ APPROVAL_PERSIST_KEY: APPROVAL_PERSIST_ALWAYS_VALUE, })), } ); } #[test] fn ctrl_c_cancels_elicitation() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); let request = from_form_request( thread_id, form_request( "Allow this request?", serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean", "title": "Confirm", "description": "Approve the pending action.", } }, "required": ["confirmed"], }), /*meta*/ None, ), ) .expect("expected supported form"); let mut overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); assert_eq!(overlay.on_ctrl_c(), CancellationEvent::Handled); let event = rx.try_recv().expect("expected resolution"); let AppEvent::SubmitThreadOp { thread_id: resolved_thread_id, op, } = event else { panic!("expected SubmitThreadOp"); }; assert_eq!(resolved_thread_id, thread_id); assert_eq!( op, Op::ResolveElicitation { server_name: "server-1".to_string(), request_id: request_id("request-1"), decision: McpServerElicitationAction::Cancel, content: None, meta: None, } ); } #[test] fn queues_requests_fifo() { let (tx, _rx) = test_sender(); let first = from_form_request( ThreadId::default(), form_request( "First", serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean", "title": "Confirm", } }, }), /*meta*/ None, ), ) .expect("expected supported form"); let second = from_form_request( ThreadId::default(), form_request( "Second", serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean", "title": "Confirm", } }, }), /*meta*/ None, ), ) .expect("expected supported form"); let third = from_form_request( ThreadId::default(), form_request( "Third", serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean", "title": "Confirm", } }, }), /*meta*/ None, ), ) .expect("expected supported form"); let mut overlay = McpServerElicitationOverlay::new( first, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); overlay.try_consume_mcp_server_elicitation_request(second); overlay.try_consume_mcp_server_elicitation_request(third); overlay.select_current_option(/*committed*/ true); overlay.submit_answers(); assert_eq!(overlay.request.message, "Second"); overlay.select_current_option(/*committed*/ true); overlay.submit_answers(); assert_eq!(overlay.request.message, "Third"); } #[test] fn resolved_request_dismisses_overlay_without_emitting_events() { let (tx, mut rx) = test_sender(); let thread_id = ThreadId::default(); let supported_form_schema = serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean", "title": "Confirm", } }, }); let mut overlay = McpServerElicitationOverlay::new( from_form_request( thread_id, form_request("First", supported_form_schema.clone(), /*meta*/ None), ) .expect("expected supported form"), tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); overlay.try_consume_mcp_server_elicitation_request( McpServerElicitationFormRequest::from_app_server_request( thread_id, request_id("request-2"), McpServerElicitationRequestParams { thread_id: "thread-1".to_string(), turn_id: Some("turn-2".to_string()), server_name: "server-1".to_string(), request: McpServerElicitationRequest::Form { meta: None, message: "Second".to_string(), requested_schema: serde_json::from_value(supported_form_schema) .expect("test schema should deserialize"), }, }, ) .expect("expected supported form"), ); assert!( overlay.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { server_name: "server-1".to_string(), request_id: request_id("request-1"), }) ); assert_eq!(overlay.request.message, "Second"); assert!(matches!( rx.try_recv(), Err(tokio::sync::mpsc::error::TryRecvError::Empty) )); assert!( overlay.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation { server_name: "server-1".to_string(), request_id: request_id("request-2"), }) ); assert!(overlay.is_complete()); assert!(matches!( rx.try_recv(), Err(tokio::sync::mpsc::error::TryRecvError::Empty) )); } #[test] fn boolean_form_snapshot() { let (tx, _rx) = test_sender(); let request = from_form_request( ThreadId::default(), form_request( "Allow this request?", serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean", "title": "Confirm", "description": "Approve the pending action.", } }, "required": ["confirmed"], }), /*meta*/ None, ), ) .expect("expected supported form"); let overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); insta::assert_snapshot!( "mcp_server_elicitation_boolean_form", render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) ); } #[test] fn approval_form_tool_approval_snapshot() { let (tx, _rx) = test_sender(); let request = from_form_request( ThreadId::default(), form_request( "Allow this request?", empty_object_schema(), tool_approval_meta( &[], /*tool_params*/ None, /*tool_params_display*/ None, ), ), ) .expect("expected approval fallback"); let overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); insta::assert_snapshot!( "mcp_server_elicitation_approval_form_without_schema", render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) ); } #[test] fn message_only_form_snapshot() { let (tx, _rx) = test_sender(); let request = from_form_request( ThreadId::default(), form_request( "Boolean elicit MCP example: do you confirm?", empty_object_schema(), /*meta*/ None, ), ) .expect("expected message-only form"); let overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); insta::assert_snapshot!( "mcp_server_elicitation_message_only_form", render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) ); } #[test] fn message_only_form_with_persist_options_snapshot() { let (tx, _rx) = test_sender(); let request = from_form_request( ThreadId::default(), form_request( "Boolean elicit MCP example: do you confirm?", empty_object_schema(), Some(serde_json::json!({ APPROVAL_PERSIST_KEY: [ APPROVAL_PERSIST_SESSION_VALUE, APPROVAL_PERSIST_ALWAYS_VALUE, ], })), ), ) .expect("expected message-only form"); let overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); insta::assert_snapshot!( "mcp_server_elicitation_message_only_form_with_persist_options", render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) ); } #[test] fn approval_form_tool_approval_with_persist_options_snapshot() { let (tx, _rx) = test_sender(); let request = from_form_request( ThreadId::default(), form_request( "Allow this request?", empty_object_schema(), tool_approval_meta( &[ APPROVAL_PERSIST_SESSION_VALUE, APPROVAL_PERSIST_ALWAYS_VALUE, ], /*tool_params*/ None, /*tool_params_display*/ None, ), ), ) .expect("expected approval fallback"); let overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); insta::assert_snapshot!( "mcp_server_elicitation_approval_form_with_session_persist", render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) ); } #[test] fn approval_form_tool_approval_with_param_summary_snapshot() { let (tx, _rx) = test_sender(); let request = from_form_request( ThreadId::default(), form_request( "Allow Calendar to create an event", empty_object_schema(), tool_approval_meta( &[], Some(serde_json::json!({ "calendar_id": "primary", "title": "Roadmap review", "notes": "This is a deliberately long note that should truncate before it turns the approval body into a giant wall of text in the TUI overlay.", "ignored_after_limit": "fourth param", })), Some(vec![ ( "calendar_id", Value::String("primary".to_string()), "Calendar", ), ( "title", Value::String("Roadmap review".to_string()), "Title", ), ( "notes", Value::String("This is a deliberately long note that should truncate before it turns the approval body into a giant wall of text in the TUI overlay.".to_string()), "Notes", ), ( "ignored_after_limit", Value::String("fourth param".to_string()), "Ignored", ), ]), ), ), ) .expect("expected approval fallback"); let overlay = McpServerElicitationOverlay::new( request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false, /*disable_paste_burst*/ false, ); insta::assert_snapshot!( "mcp_server_elicitation_approval_form_with_param_summary", render_snapshot(&overlay, Rect::new(0, 0, 120, 16)) ); }}