Codex Handbook
tui/src/bottom_pane/mcp_server_elicitation.rs 2732 lines
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(&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,}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))        );    }}