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