Files
codex/codex-rs/tui/src/bottom_pane/mcp_server_elicitation.rs
T
Felipe Coury 1e85ca099e Support two-stroke TUI key chords (#36511)
## 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
2026-08-01 17:43:03 +00:00

2821 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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::ShortcutHint;
use crate::keymap::KeymapContext;
use crate::keymap::ListAction;
use crate::keymap::ListKeymap;
use crate::keymap::RuntimeKeymap;
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.clone(),
request_id,
meta.as_ref(),
message.clone(),
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);
let is_tool_approval = meta
.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)
} 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, 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, 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(&param.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,
composer_submit_hint: Option<ShortcutHint>,
}
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,
RuntimeKeymap::defaults(),
)
}
pub(crate) fn new_with_keymap(
request: McpServerElicitationFormRequest,
app_event_tx: AppEventSender,
has_input_focus: bool,
enhanced_keys_supported: bool,
disable_paste_burst: bool,
keymap: RuntimeKeymap,
) -> Self {
let composer_submit_hint = keymap.primary_hint(KeymapContext::Composer, "submit");
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_keymap_bindings(&keymap);
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: keymap.list,
composer_submit_hint,
};
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();
let submit_hint = if self.current_field_is_select() {
self.list_keymap.primary_hint(ListAction::Accept)
} else {
self.composer_submit_hint
};
if let Some(submit_hint) = submit_hint.map(ShortcutHint::display_label) {
if self.field_count() == 1 {
tips.push(FooterTip::highlighted(format!("{submit_hint} to submit")));
} else if is_last_field {
tips.push(FooterTip::highlighted(format!(
"{submit_hint} to submit all"
)));
} else {
tips.push(FooterTip::new(format!("{submit_hint} 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 keymap_contexts(&self) -> crate::keymap::KeymapContextSet {
if self.current_field_is_select() {
crate::keymap::KeymapContextSet::new(crate::keymap::KeymapContext::List)
} else {
self.composer.keymap_contexts()
}
}
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.current_field_is_select()
&& self.list_keymap.action_for(key_event) == Some(ListAction::Cancel)
{
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.action_for(key_event) == Some(ListAction::MoveLeft) =>
{
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.action_for(key_event) == Some(ListAction::MoveRight) =>
{
self.move_field(/*next*/ true);
return;
}
_ => {}
}
if self.current_field_is_select() {
self.validation_error = None;
let options_len = self.options_len();
match (self.list_keymap.action_for(key_event), key_event.code) {
(Some(ListAction::MoveUp), _) | (_, KeyCode::Up | KeyCode::Char('k')) => {
if let Some(answer) = self.current_answer_mut() {
answer.selection.move_up_wrap(options_len);
answer.answer_committed = false;
}
}
(Some(ListAction::MoveDown), _) | (_, 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),
(Some(ListAction::Accept), _) | (_, 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 codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
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);
overlay.list_keymap.accept.clear();
assert!(
overlay
.footer_tips()
.iter()
.all(|tip| !tip.text.contains("submit"))
);
}
#[test]
fn text_fields_inherit_composer_chord_context_and_submit_binding() {
let (tx, _rx) = test_sender();
let thread_id = ThreadId::default();
let request = from_form_request(
thread_id,
form_request(
"Enter a name",
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name",
}
},
"required": ["name"],
}),
/*meta*/ None,
),
)
.expect("supported text field");
let mut config = TuiKeymap::default();
config.composer.submit = Some(KeybindingsSpec::One(KeybindingSpec(
"ctrl-x enter".to_string(),
)));
let keymap = RuntimeKeymap::from_config(&config).expect("valid MCP composer chord");
let mut overlay = McpServerElicitationOverlay::new_with_keymap(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ true, keymap,
);
let snapshot = render_snapshot(
&overlay,
Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 10,
),
)
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(
"mcp_server_elicitation_text_configured_key_chords",
snapshot
);
assert!(
overlay
.keymap_contexts()
.contains(crate::keymap::KeymapContext::Composer)
);
overlay.composer_submit_hint = None;
assert!(
overlay
.footer_tips()
.iter()
.all(|tip| !tip.text.contains("submit"))
);
}
#[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))
);
}
}