Compare commits

...

4 Commits

Author SHA1 Message Date
colby-oai
2876fcfdba codex: fix remaining rust-ci lint failure on PR #13134 2026-02-28 14:20:13 -05:00
colby-oai
020071a8ef codex: fix CI failure on PR #13134 2026-02-28 14:05:00 -05:00
colby-oai
d1ad017629 Remove tool name parenthesis 2026-02-28 11:47:37 -05:00
colby-oai
54690d39d2 Explain running request input tests 2026-02-28 11:33:38 -05:00
16 changed files with 468 additions and 96 deletions

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::time::Duration;
use std::time::Instant;
@@ -106,10 +108,13 @@ pub(crate) async fn handle_mcp_tool_call(
sess.as_ref(),
turn_context,
&call_id,
&server,
&tool_name,
metadata.as_ref(),
app_tool_policy.approval,
McpToolApprovalRequest {
server: &server,
tool_name: &tool_name,
arguments: arguments_value.as_ref(),
metadata: metadata.as_ref(),
approval_mode: app_tool_policy.approval,
},
)
.await
{
@@ -315,6 +320,7 @@ struct McpToolApprovalMetadata {
annotations: Option<ToolAnnotations>,
connector_id: Option<String>,
connector_name: Option<String>,
input_schema: serde_json::Value,
tool_title: Option<String>,
}
@@ -331,20 +337,27 @@ struct McpToolApprovalKey {
tool_name: String,
}
struct McpToolApprovalRequest<'a> {
server: &'a str,
tool_name: &'a str,
arguments: Option<&'a serde_json::Value>,
metadata: Option<&'a McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
}
async fn maybe_request_mcp_tool_approval(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
server: &str,
tool_name: &str,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
request: McpToolApprovalRequest<'_>,
) -> Option<McpToolApprovalDecision> {
if approval_mode == AppToolApproval::Approve {
if request.approval_mode == AppToolApproval::Approve {
return None;
}
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
if approval_mode == AppToolApproval::Auto {
let annotations = request
.metadata
.and_then(|metadata| metadata.annotations.as_ref());
if request.approval_mode == AppToolApproval::Auto {
if is_full_access_mode(turn_context) {
return None;
}
@@ -353,15 +366,17 @@ async fn maybe_request_mcp_tool_approval(
}
}
let approval_key = if approval_mode == AppToolApproval::Auto {
let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone());
if server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
let approval_key = if request.approval_mode == AppToolApproval::Auto {
let connector_id = request
.metadata
.and_then(|metadata| metadata.connector_id.clone());
if request.server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
None
} else {
Some(McpToolApprovalKey {
server: server.to_string(),
server: request.server.to_string(),
connector_id,
tool_name: tool_name.to_string(),
tool_name: request.tool_name.to_string(),
})
}
} else {
@@ -376,11 +391,10 @@ async fn maybe_request_mcp_tool_approval(
let question_id = format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}");
let question = build_mcp_tool_approval_question(
question_id.clone(),
server,
tool_name,
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
annotations,
request.server,
request.tool_name,
request.arguments,
request.metadata,
approval_key.is_some(),
);
let args = RequestUserInputArgs {
@@ -391,7 +405,7 @@ async fn maybe_request_mcp_tool_approval(
.await;
let decision = normalize_approval_decision_for_mode(
parse_mcp_tool_approval_response(response, &question_id),
approval_mode,
request.approval_mode,
);
if matches!(decision, McpToolApprovalDecision::AcceptAndRemember)
&& let Some(key) = approval_key
@@ -428,6 +442,9 @@ async fn lookup_mcp_tool_metadata(
annotations: tool_info.tool.annotations,
connector_id: tool_info.connector_id,
connector_name: tool_info.connector_name,
input_schema: serde_json::Value::Object(
tool_info.tool.input_schema.as_ref().clone(),
),
tool_title: tool_info.tool.title,
})
} else {
@@ -465,11 +482,11 @@ fn build_mcp_tool_approval_question(
question_id: String,
server: &str,
tool_name: &str,
tool_title: Option<&str>,
connector_name: Option<&str>,
annotations: Option<&ToolAnnotations>,
arguments: Option<&serde_json::Value>,
metadata: Option<&McpToolApprovalMetadata>,
allow_remember_option: bool,
) -> RequestUserInputQuestion {
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
let destructive =
annotations.and_then(|annotations| annotations.destructive_hint) == Some(true);
let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true);
@@ -480,7 +497,9 @@ fn build_mcp_tool_approval_question(
(false, false) => "may have side effects",
};
let tool_label = tool_title.unwrap_or(tool_name);
let tool_title = metadata.and_then(|metadata| metadata.tool_title.as_deref());
let tool_label = format_mcp_tool_label(tool_name, tool_title);
let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref());
let app_label = connector_name
.map(|name| format!("The {name} app"))
.unwrap_or_else(|| {
@@ -490,9 +509,16 @@ fn build_mcp_tool_approval_question(
format!("The {server} MCP server")
}
});
let question = format!(
"{app_label} wants to run the tool \"{tool_label}\", which {reason}. Allow this action?"
);
let mut question_sections = vec![format!(
"{app_label} wants to run the tool {tool_label}, which {reason}."
)];
if let Some(tool_call_details) =
format_mcp_tool_call_details(arguments, metadata.map(|metadata| &metadata.input_schema))
{
question_sections.push(tool_call_details);
}
question_sections.push("Allow this action?".to_string());
let question = question_sections.join("\n\n");
let mut options = vec![RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
@@ -517,7 +543,7 @@ fn build_mcp_tool_approval_question(
RequestUserInputQuestion {
id: question_id,
header: "Approve app tool call?".to_string(),
header: build_mcp_tool_approval_header(server, tool_name, tool_title, connector_name),
question,
is_other: false,
is_secret: false,
@@ -525,6 +551,119 @@ fn build_mcp_tool_approval_question(
}
}
fn build_mcp_tool_approval_header(
server: &str,
tool_name: &str,
tool_title: Option<&str>,
connector_name: Option<&str>,
) -> String {
let tool_label = tool_title.unwrap_or(tool_name);
match connector_name {
Some(connector_name) => format!("Approve {connector_name}: {tool_label}?"),
None if server == CODEX_APPS_MCP_SERVER_NAME => format!("Approve app tool: {tool_label}?"),
None => format!("Approve {server}: {tool_label}?"),
}
}
fn format_mcp_tool_label(tool_name: &str, tool_title: Option<&str>) -> String {
match tool_title {
Some(tool_title) => format!("\"{tool_title}\""),
None => format!("\"{tool_name}\""),
}
}
fn format_mcp_tool_call_details(
arguments: Option<&serde_json::Value>,
input_schema: Option<&serde_json::Value>,
) -> Option<String> {
const MAX_FIELDS: usize = 8;
const MAX_VALUE_CHARS: usize = 160;
let arguments = arguments?;
match arguments {
serde_json::Value::Object(arguments) if arguments.is_empty() => None,
serde_json::Value::Object(arguments) => {
let required_fields = input_schema
.and_then(|schema| schema.get("required"))
.and_then(serde_json::Value::as_array)
.map(|fields| {
fields
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToString::to_string)
.collect::<HashSet<_>>()
})
.unwrap_or_default();
let schema_field_positions = input_schema
.and_then(|schema| schema.get("properties"))
.and_then(serde_json::Value::as_object)
.map(|properties| {
properties
.keys()
.enumerate()
.map(|(idx, key)| (key.clone(), idx))
.collect::<HashMap<_, _>>()
})
.unwrap_or_default();
let total_fields = arguments.len();
let mut lines = vec!["Tool call details:".to_string()];
let mut entries = arguments.iter().collect::<Vec<_>>();
entries.sort_by(|(left_key, _), (right_key, _)| {
mcp_tool_detail_sort_key(left_key, &required_fields, &schema_field_positions).cmp(
&mcp_tool_detail_sort_key(right_key, &required_fields, &schema_field_positions),
)
});
for (idx, (key, value)) in entries.into_iter().enumerate() {
if idx >= MAX_FIELDS {
let remaining = total_fields.saturating_sub(MAX_FIELDS);
let suffix = if remaining == 1 { "" } else { "s" };
lines.push(format!("- … {remaining} more field{suffix}"));
break;
}
let rendered_value = truncate_for_approval(
serde_json::to_string(value).unwrap_or_else(|_| "<unavailable>".to_string()),
MAX_VALUE_CHARS,
);
lines.push(format!("- {key}: {rendered_value}"));
}
Some(lines.join("\n"))
}
other => Some(format!(
"Tool call details:\n- arguments: {}",
truncate_for_approval(
serde_json::to_string(other).unwrap_or_else(|_| "<unavailable>".to_string()),
MAX_VALUE_CHARS,
)
)),
}
}
fn mcp_tool_detail_sort_key<'a>(
key: &'a str,
required_fields: &HashSet<String>,
schema_field_positions: &HashMap<String, usize>,
) -> (usize, usize, &'a str) {
(
usize::from(!required_fields.contains(key)),
schema_field_positions
.get(key)
.copied()
.unwrap_or(usize::MAX),
key,
)
}
fn truncate_for_approval(value: String, max_chars: usize) -> String {
let char_count = value.chars().count();
if char_count <= max_chars {
return value;
}
let keep = max_chars.saturating_sub(1);
let truncated = value.chars().take(keep).collect::<String>();
format!("{truncated}")
}
fn parse_mcp_tool_approval_response(
response: Option<RequestUserInputResponse>,
question_id: &str,
@@ -632,6 +771,21 @@ mod tests {
}
}
fn approval_metadata(
tool_title: Option<&str>,
connector_name: Option<&str>,
annotations: Option<ToolAnnotations>,
input_schema: serde_json::Value,
) -> McpToolApprovalMetadata {
McpToolApprovalMetadata {
annotations,
connector_id: None,
connector_name: connector_name.map(str::to_string),
input_schema,
tool_title: tool_title.map(str::to_string),
}
}
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), None);
@@ -663,20 +817,25 @@ mod tests {
#[test]
fn custom_mcp_tool_question_mentions_server_name() {
let metadata = approval_metadata(
Some("Run Action"),
None,
Some(annotations(Some(false), Some(true), None)),
serde_json::json!({}),
);
let question = build_mcp_tool_approval_question(
"q".to_string(),
"custom_server",
"run_action",
Some("Run Action"),
None,
Some(&annotations(Some(false), Some(true), None)),
Some(&metadata),
true,
);
assert_eq!(question.header, "Approve app tool call?");
assert_eq!(question.header, "Approve custom_server: Run Action?");
assert_eq!(
question.question,
"The custom_server MCP server wants to run the tool \"Run Action\", which may modify or delete data. Allow this action?"
"The custom_server MCP server wants to run the tool \"Run Action\", which may modify or delete data.\n\nAllow this action?"
);
assert!(
question
@@ -690,13 +849,18 @@ mod tests {
#[test]
fn codex_apps_tool_question_keeps_legacy_app_label() {
let metadata = approval_metadata(
Some("Run Action"),
None,
Some(annotations(Some(false), Some(true), None)),
serde_json::json!({}),
);
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
Some("Run Action"),
None,
Some(&annotations(Some(false), Some(true), None)),
Some(&metadata),
true,
);
@@ -707,6 +871,44 @@ mod tests {
);
}
#[test]
fn app_tool_question_orders_tool_call_details_generically() {
let metadata = approval_metadata(
Some("Create Issue"),
Some("Linear"),
Some(annotations(Some(false), Some(true), Some(true))),
serde_json::json!({
"type": "object",
"required": ["projectId", "title"],
"properties": {
"body": { "type": "string" },
"description": { "type": "string" },
"projectId": { "type": "string" },
"title": { "type": "string" }
}
}),
);
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"create_issue",
Some(&serde_json::json!({
"description": "Audit approval prompt copy",
"projectId": "proj_123",
"body": "Draft email body",
"title": "Approval prompt follow-up",
})),
Some(&metadata),
true,
);
assert_eq!(question.header, "Approve Linear: Create Issue?");
assert_eq!(
question.question,
"The Linear app wants to run the tool \"Create Issue\", which may modify data and access external systems.\n\nTool call details:\n- projectId: \"proj_123\"\n- title: \"Approval prompt follow-up\"\n- body: \"Draft email body\"\n- description: \"Audit approval prompt copy\"\n\nAllow this action?"
);
}
#[test]
fn sanitize_mcp_tool_result_for_model_rewrites_image_content() {
let result = Ok(CallToolResult {

View File

@@ -6,6 +6,7 @@ use super::RequestUserInputOverlay;
pub(super) struct LayoutSections {
pub(super) progress_area: Rect,
pub(super) question_area: Rect,
pub(super) header_lines: Vec<String>,
// Wrapped question text lines to render in the question area.
pub(super) question_lines: Vec<String>,
pub(super) options_area: Rect,
@@ -21,37 +22,42 @@ impl RequestUserInputOverlay {
let notes_visible = !has_options || self.notes_ui_visible();
let footer_pref = self.footer_required_height(area.width);
let notes_pref_height = self.notes_input_height(area.width);
let mut header_lines = self.wrapped_header_lines(area.width);
let mut question_lines = self.wrapped_question_lines(area.width);
let header_height = header_lines.len() as u16;
let question_height = question_lines.len() as u16;
let prompt_height = header_height.saturating_add(question_height);
let layout = if has_options {
self.layout_with_options(
OptionsLayoutArgs {
available_height: area.height,
width: area.width,
question_height,
prompt_height,
notes_pref_height,
footer_pref,
notes_visible,
},
&mut header_lines,
&mut question_lines,
)
} else {
self.layout_without_options(
area.height,
question_height,
prompt_height,
notes_pref_height,
footer_pref,
&mut header_lines,
&mut question_lines,
)
};
let (progress_area, question_area, options_area, notes_area) =
self.build_layout_areas(area, layout);
LayoutSections {
progress_area,
question_area,
header_lines,
question_lines,
options_area,
notes_area,
@@ -63,26 +69,44 @@ impl RequestUserInputOverlay {
fn layout_with_options(
&self,
args: OptionsLayoutArgs,
header_lines: &mut Vec<String>,
question_lines: &mut Vec<String>,
) -> LayoutPlan {
let OptionsLayoutArgs {
available_height,
width,
mut question_height,
prompt_height,
notes_pref_height,
footer_pref,
notes_visible,
} = args;
let min_options_height = available_height.min(1);
let max_question_height = available_height.saturating_sub(min_options_height);
if question_height > max_question_height {
question_height = max_question_height;
question_lines.truncate(question_height as usize);
let max_prompt_height = available_height.saturating_sub(min_options_height);
if prompt_height > max_prompt_height {
let overflow = prompt_height.saturating_sub(max_prompt_height);
let question_overflow = overflow.min(question_lines.len() as u16);
if question_overflow > 0 {
let remaining_questions = question_lines
.len()
.saturating_sub(question_overflow as usize);
question_lines.truncate(remaining_questions);
}
let header_overflow = overflow.saturating_sub(question_overflow);
if header_overflow > 0 {
let remaining_headers = header_lines.len().saturating_sub(header_overflow as usize);
header_lines.truncate(remaining_headers);
}
}
let prompt_height = header_lines
.len()
.saturating_add(question_lines.len())
.try_into()
.unwrap_or(u16::MAX);
self.layout_with_options_normal(
OptionsNormalArgs {
available_height,
question_height,
prompt_height,
notes_pref_height,
footer_pref,
notes_visible,
@@ -103,18 +127,18 @@ impl RequestUserInputOverlay {
) -> LayoutPlan {
let OptionsNormalArgs {
available_height,
question_height,
prompt_height,
notes_pref_height,
footer_pref,
notes_visible,
} = args;
let max_options_height = available_height.saturating_sub(question_height);
let max_options_height = available_height.saturating_sub(prompt_height);
let min_options_height = max_options_height.min(1);
let mut options_height = options
.preferred
.min(max_options_height)
.max(min_options_height);
let used = question_height.saturating_add(options_height);
let used = prompt_height.saturating_add(options_height);
let mut remaining = available_height.saturating_sub(used);
// When notes are hidden, prefer to reserve room for progress, footer,
@@ -159,7 +183,7 @@ impl RequestUserInputOverlay {
let grow_by = remaining.min(options.full.saturating_sub(options_height));
options_height = options_height.saturating_add(grow_by);
return LayoutPlan {
question_height,
prompt_height,
progress_height,
spacer_after_question,
options_height,
@@ -185,7 +209,7 @@ impl RequestUserInputOverlay {
notes_height = notes_height.saturating_add(remaining);
LayoutPlan {
question_height,
prompt_height,
progress_height,
spacer_after_question,
options_height,
@@ -203,18 +227,24 @@ impl RequestUserInputOverlay {
fn layout_without_options(
&self,
available_height: u16,
question_height: u16,
prompt_height: u16,
notes_pref_height: u16,
footer_pref: u16,
header_lines: &mut Vec<String>,
question_lines: &mut Vec<String>,
) -> LayoutPlan {
let required = question_height;
let required = prompt_height;
if required > available_height {
self.layout_without_options_tight(available_height, question_height, question_lines)
self.layout_without_options_tight(
available_height,
prompt_height,
header_lines,
question_lines,
)
} else {
self.layout_without_options_normal(
available_height,
question_height,
prompt_height,
notes_pref_height,
footer_pref,
)
@@ -225,15 +255,36 @@ impl RequestUserInputOverlay {
fn layout_without_options_tight(
&self,
available_height: u16,
question_height: u16,
prompt_height: u16,
header_lines: &mut Vec<String>,
question_lines: &mut Vec<String>,
) -> LayoutPlan {
let max_question_height = available_height;
let adjusted_question_height = question_height.min(max_question_height);
question_lines.truncate(adjusted_question_height as usize);
let max_prompt_height = available_height;
if prompt_height > max_prompt_height {
let overflow = prompt_height.saturating_sub(max_prompt_height);
let question_overflow = overflow.min(question_lines.len() as u16);
if question_overflow > 0 {
let remaining_questions = question_lines
.len()
.saturating_sub(question_overflow as usize);
question_lines.truncate(remaining_questions);
}
let header_overflow = overflow.saturating_sub(question_overflow);
if header_overflow > 0 {
let remaining_headers = header_lines.len().saturating_sub(header_overflow as usize);
header_lines.truncate(remaining_headers);
}
}
let prompt_height = header_lines
.len()
.saturating_add(question_lines.len())
.try_into()
.unwrap_or(u16::MAX);
LayoutPlan {
question_height: adjusted_question_height,
prompt_height,
progress_height: 0,
spacer_after_question: 0,
options_height: 0,
@@ -247,11 +298,11 @@ impl RequestUserInputOverlay {
fn layout_without_options_normal(
&self,
available_height: u16,
question_height: u16,
prompt_height: u16,
notes_pref_height: u16,
footer_pref: u16,
) -> LayoutPlan {
let required = question_height;
let required = prompt_height;
let mut remaining = available_height.saturating_sub(required);
let mut notes_height = notes_pref_height.min(remaining);
remaining = remaining.saturating_sub(notes_height);
@@ -268,7 +319,7 @@ impl RequestUserInputOverlay {
notes_height = notes_height.saturating_add(remaining);
LayoutPlan {
question_height,
prompt_height,
progress_height,
spacer_after_question: 0,
options_height: 0,
@@ -301,9 +352,9 @@ impl RequestUserInputOverlay {
x: area.x,
y: cursor_y,
width: area.width,
height: heights.question_height,
height: heights.prompt_height,
};
cursor_y = cursor_y.saturating_add(heights.question_height);
cursor_y = cursor_y.saturating_add(heights.prompt_height);
cursor_y = cursor_y.saturating_add(heights.spacer_after_question);
let options_area = Rect {
@@ -329,7 +380,7 @@ impl RequestUserInputOverlay {
#[derive(Clone, Copy, Debug)]
struct LayoutPlan {
progress_height: u16,
question_height: u16,
prompt_height: u16,
spacer_after_question: u16,
options_height: u16,
spacer_after_options: u16,
@@ -341,7 +392,7 @@ struct LayoutPlan {
struct OptionsLayoutArgs {
available_height: u16,
width: u16,
question_height: u16,
prompt_height: u16,
notes_pref_height: u16,
footer_pref: u16,
notes_visible: bool,
@@ -350,7 +401,7 @@ struct OptionsLayoutArgs {
#[derive(Clone, Copy, Debug)]
struct OptionsNormalArgs {
available_height: u16,
question_height: u16,
prompt_height: u16,
notes_pref_height: u16,
footer_pref: u16,
notes_visible: bool,

View File

@@ -35,6 +35,7 @@ use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::TextElement;
use textwrap::Options;
use unicode_width::UnicodeWidthStr;
const NOTES_PLACEHOLDER: &str = "Add notes";
@@ -248,12 +249,13 @@ impl RequestUserInputOverlay {
pub(super) fn wrapped_question_lines(&self, width: u16) -> Vec<String> {
self.current_question()
.map(|q| {
textwrap::wrap(&q.question, width.max(1) as usize)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
})
.map(|question| wrap_plain_text_lines(&question.question, width))
.unwrap_or_default()
}
pub(super) fn wrapped_header_lines(&self, width: u16) -> Vec<String> {
self.current_question()
.map(|question| wrap_plain_text_lines(&question.header, width))
.unwrap_or_default()
}
@@ -985,6 +987,28 @@ impl RequestUserInputOverlay {
}
}
fn wrap_plain_text_lines(text: &str, width: u16) -> Vec<String> {
let width = width.max(1) as usize;
text.split('\n')
.flat_map(|line| {
let line = line.trim_end_matches('\r');
if line.is_empty() {
return vec![String::new()];
}
let options = if line.starts_with("- ") {
Options::new(width).subsequent_indent(" ")
} else {
Options::new(width)
};
textwrap::wrap(line, options)
.into_iter()
.map(std::borrow::Cow::into_owned)
.collect::<Vec<_>>()
})
.collect()
}
impl BottomPaneView for RequestUserInputOverlay {
fn prefer_esc_to_handle_key_event(&self) -> bool {
true
@@ -1450,6 +1474,30 @@ mod tests {
}
}
fn approval_style_question(id: &str) -> RequestUserInputQuestion {
RequestUserInputQuestion {
id: id.to_string(),
header: "Approve Gmail: Send Email?".to_string(),
question: "The Gmail app wants to run the tool \"Send Email\" (send_email), which may modify data and access external systems.\n\nTool call details:\n- to: [\"user@example.com\"]\n- subject: \"Quarterly update\"\n- body: \"Draft email body\"\n\nAllow this action?".to_string(),
is_other: false,
is_secret: false,
options: Some(vec![
RequestUserInputQuestionOption {
label: "Approve Once".to_string(),
description: "Run the tool and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: "Deny".to_string(),
description: "Decline this tool call and continue.".to_string(),
},
RequestUserInputQuestionOption {
label: "Cancel".to_string(),
description: "Cancel this tool call.".to_string(),
},
]),
}
}
fn request_event(
turn_id: &str,
questions: Vec<RequestUserInputQuestion>,
@@ -2520,12 +2568,16 @@ mod tests {
);
let width = 48u16;
let question_height = overlay.wrapped_question_lines(width).len() as u16;
let prompt_height = overlay
.wrapped_header_lines(width)
.len()
.saturating_add(overlay.wrapped_question_lines(width).len())
as u16;
let options_height = overlay.options_required_height(width);
let extras = 1u16 // progress
.saturating_add(DESIRED_SPACERS_BETWEEN_SECTIONS)
.saturating_add(overlay.footer_required_height(width));
let height = question_height
let height = prompt_height
.saturating_add(options_height)
.saturating_add(extras);
let sections = overlay.layout_sections(Rect::new(0, 0, width, height));
@@ -2619,10 +2671,14 @@ mod tests {
}
let width = 110u16;
let question_height = overlay.wrapped_question_lines(width).len() as u16;
let prompt_height = overlay
.wrapped_header_lines(width)
.len()
.saturating_add(overlay.wrapped_question_lines(width).len())
as u16;
let options_height = overlay.options_required_height(width);
let height = 1u16
.saturating_add(question_height)
.saturating_add(prompt_height)
.saturating_add(options_height)
.saturating_add(8);
let area = Rect::new(0, 0, width, height);
@@ -2632,6 +2688,23 @@ mod tests {
);
}
#[test]
fn request_user_input_multiline_approval_snapshot() {
let (tx, _rx) = test_sender();
let overlay = RequestUserInputOverlay::new(
request_event("turn-1", vec![approval_style_question("q1")]),
tx,
true,
false,
false,
);
let area = Rect::new(0, 0, 100, 18);
insta::assert_snapshot!(
"request_user_input_multiline_approval",
render_snapshot(&overlay, area)
);
}
#[test]
fn request_user_input_long_option_text_snapshot() {
let (tx, _rx) = test_sender();

View File

@@ -67,6 +67,7 @@ impl Renderable for RequestUserInputOverlay {
let inner = menu_surface_inset(outer);
let inner_width = inner.width.max(1);
let has_options = self.has_options();
let header_height = self.wrapped_header_lines(inner_width).len();
let question_height = self.wrapped_question_lines(inner_width).len();
let options_height = if has_options {
self.options_preferred_height(inner_width) as usize
@@ -94,7 +95,8 @@ impl Renderable for RequestUserInputOverlay {
// Tight minimum height: progress + question + (optional) titles/options
// + notes composer + footer + menu padding.
let mut height = question_height
let mut height = header_height
.saturating_add(question_height)
.saturating_add(options_height)
.saturating_add(spacer_rows)
.saturating_add(notes_height)
@@ -278,17 +280,39 @@ impl RequestUserInputOverlay {
};
Paragraph::new(progress_line).render(sections.progress_area, buf);
// Question prompt text.
let question_y = sections.question_area.y;
let answered =
self.is_question_answered(self.current_index(), &self.composer.current_text());
for (offset, line) in sections.question_lines.iter().enumerate() {
if question_y.saturating_add(offset as u16)
let mut prompt_y = sections.question_area.y;
for (offset, line) in sections.header_lines.iter().enumerate() {
if prompt_y.saturating_add(offset as u16)
>= sections.question_area.y + sections.question_area.height
{
break;
}
let question_line = if answered {
let header_line = if answered {
Line::from(line.clone()).bold()
} else {
Line::from(line.clone()).bold().cyan()
};
Paragraph::new(header_line).render(
Rect {
x: sections.question_area.x,
y: prompt_y.saturating_add(offset as u16),
width: sections.question_area.width,
height: 1,
},
buf,
);
}
prompt_y = prompt_y.saturating_add(sections.header_lines.len() as u16);
for (offset, line) in sections.question_lines.iter().enumerate() {
if prompt_y.saturating_add(offset as u16)
>= sections.question_area.y + sections.question_area.height
{
break;
}
let question_line = if answered || !sections.header_lines.is_empty() {
Line::from(line.clone())
} else {
Line::from(line.clone()).cyan()
@@ -296,7 +320,7 @@ impl RequestUserInputOverlay {
Paragraph::new(question_line).render(
Rect {
x: sections.question_area.x,
y: question_y.saturating_add(offset as u16),
y: prompt_y.saturating_add(offset as u16),
width: sections.question_area.width,
height: 1,
},

View File

@@ -1,10 +1,10 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2600
expression: "render_snapshot(&overlay, area)"
---
Question 1/2 (2 unanswered)
Pick one
Choose an option.
1. Option 1 First choice.

View File

@@ -4,10 +4,10 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Goal
Share details.
Type your answer (optional)
enter to submit answer | esc to interrupt

View File

@@ -4,9 +4,9 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Next Step
What would you like to do next?
2. Run tests Pick a crate and run its tests.
3. Review a diff Summarize or review current changes.
4. Refactor Tighten structure and remove dead code.

View File

@@ -4,6 +4,7 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Status
Choose one option.
1. Job: running/completed/failed/expired; Run/Experiment: succeeded/failed/ Keep async job statuses for

View File

@@ -1,10 +1,10 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2744
expression: "render_snapshot(&overlay, area)"
---
Question 1/2 (2 unanswered)
Area
Choose an option.
1. Option 1 First choice.

View File

@@ -1,10 +1,10 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2770
expression: "render_snapshot(&overlay, area)"
---
Question 2/2 (2 unanswered)
Goal
Share details.
Type your answer (optional)
@@ -12,5 +12,4 @@ expression: "render_snapshot(&overlay, area)"
enter to submit all | ctrl + p / ctrl + n change question | esc to interrupt

View File

@@ -0,0 +1,21 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Approve Gmail: Send Email?
The Gmail app wants to run the tool "Send Email" (send_email), which may modify data and access
external systems.
Tool call details:
- to: ["user@example.com"]
- subject: "Quarterly update"
- body: "Draft email body"
Allow this action?
1. Approve Once Run the tool and continue.
2. Deny Decline this tool call and continue.
option 1/3 | tab to add notes | enter to submit answer | esc to interrupt

View File

@@ -4,6 +4,7 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Area
Choose an option.
1. Option 1 First choice.

View File

@@ -1,10 +1,10 @@
---
source: tui/src/bottom_pane/request_user_input/mod.rs
assertion_line: 2321
expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Area
Choose an option.
1. Option 1 First choice.
@@ -16,5 +16,4 @@ expression: "render_snapshot(&overlay, area)"
tab or esc to clear notes | enter to submit answer

View File

@@ -4,12 +4,12 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Next Step
What would you like to do next?
1. Discuss a code change (Recommended) Walk through a plan and edit code together.
2. Run tests Pick a crate and run its tests.
3. Review a diff Summarize or review current changes.
4. Refactor Tighten structure and remove dead code.
5. Ship it Finalize and open a PR.
tab to add notes | enter to submit answer | esc to interrupt
option 4/5 | tab to add notes | enter to submit answer | esc to interrupt

View File

@@ -4,10 +4,10 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Area
Choose an option.
1. Option 1 First choice.
2. Option 2 Second choice.
3. Option 3 Third choice.
tab to add notes | enter to submit answer | esc to interrupt
option 1/3 | tab to add notes | enter to submit answer | esc to interrupt

View File

@@ -4,6 +4,7 @@ expression: "render_snapshot(&overlay, area)"
---
Question 1/1 (1 unanswered)
Next Step
Choose the next step for this task.
1. Discuss a code change Walk through a plan, then implement it together with careful checks.