From 54690d39d2685e4baf4fe90f007d60cc51e8ed59 Mon Sep 17 00:00:00 2001 From: colby-oai <228809017+colby-oai@users.noreply.github.com> Date: Sat, 28 Feb 2026 11:33:38 -0500 Subject: [PATCH] Explain running request input tests --- codex-rs/core/src/mcp_tool_call.rs | 186 +++++++++++++++++- .../bottom_pane/request_user_input/layout.rs | 113 ++++++++--- .../src/bottom_pane/request_user_input/mod.rs | 93 ++++++++- .../bottom_pane/request_user_input/render.rs | 38 +++- ...tests__request_user_input_footer_wrap.snap | 2 +- ...t__tests__request_user_input_freeform.snap | 2 +- ...uest_user_input_hidden_options_footer.snap | 2 +- ...__request_user_input_long_option_text.snap | 1 + ...quest_user_input_multi_question_first.snap | 2 +- ...equest_user_input_multi_question_last.snap | 3 +- ...request_user_input_multiline_approval.snap | 21 ++ ...ut__tests__request_user_input_options.snap | 1 + ...uest_user_input_options_notes_visible.snap | 3 +- ..._request_user_input_scrolling_options.snap | 4 +- ...ests__request_user_input_tight_height.snap | 4 +- ...s__request_user_input_wrapped_options.snap | 1 + 16 files changed, 409 insertions(+), 67 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multiline_approval.snap diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 63f83bb3f8..0d729ca2c1 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; +use std::collections::HashSet; use std::time::Duration; use std::time::Instant; @@ -108,6 +110,7 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, &server, &tool_name, + arguments_value.as_ref(), metadata.as_ref(), app_tool_policy.approval, ) @@ -315,6 +318,7 @@ struct McpToolApprovalMetadata { annotations: Option, connector_id: Option, connector_name: Option, + input_schema: serde_json::Value, tool_title: Option, } @@ -337,6 +341,7 @@ async fn maybe_request_mcp_tool_approval( call_id: &str, server: &str, tool_name: &str, + arguments: Option<&serde_json::Value>, metadata: Option<&McpToolApprovalMetadata>, approval_mode: AppToolApproval, ) -> Option { @@ -378,6 +383,8 @@ async fn maybe_request_mcp_tool_approval( question_id.clone(), server, tool_name, + arguments, + metadata.map(|metadata| &metadata.input_schema), metadata.and_then(|metadata| metadata.tool_title.as_deref()), metadata.and_then(|metadata| metadata.connector_name.as_deref()), annotations, @@ -428,6 +435,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,6 +475,8 @@ fn build_mcp_tool_approval_question( question_id: String, server: &str, tool_name: &str, + arguments: Option<&serde_json::Value>, + input_schema: Option<&serde_json::Value>, tool_title: Option<&str>, connector_name: Option<&str>, annotations: Option<&ToolAnnotations>, @@ -480,7 +492,7 @@ fn build_mcp_tool_approval_question( (false, false) => "may have side effects", }; - let tool_label = tool_title.unwrap_or(tool_name); + let tool_label = format_mcp_tool_label(tool_name, tool_title); let app_label = connector_name .map(|name| format!("The {name} app")) .unwrap_or_else(|| { @@ -490,9 +502,14 @@ 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, 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 +534,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 +542,122 @@ 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) if tool_title != tool_name => { + format!("\"{tool_title}\" ({tool_name})") + } + 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 { + 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::>() + }) + .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::>() + }) + .unwrap_or_default(); + let total_fields = arguments.len(); + let mut lines = vec!["Tool call details:".to_string()]; + let mut entries = arguments.iter().collect::>(); + 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(|_| "".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(|_| "".to_string()), + MAX_VALUE_CHARS, + ) + )), + } +} + +fn mcp_tool_detail_sort_key<'a>( + key: &'a str, + required_fields: &HashSet, + schema_field_positions: &HashMap, +) -> (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::(); + format!("{truncated}…") +} + fn parse_mcp_tool_approval_response( response: Option, question_id: &str, @@ -667,16 +800,18 @@ mod tests { "q".to_string(), "custom_server", "run_action", + None, + None, Some("Run Action"), None, Some(&annotations(Some(false), Some(true), None)), 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\" (run_action), which may modify or delete data.\n\nAllow this action?" ); assert!( question @@ -694,6 +829,8 @@ mod tests { "q".to_string(), CODEX_APPS_MCP_SERVER_NAME, "run_action", + None, + None, Some("Run Action"), None, Some(&annotations(Some(false), Some(true), None)), @@ -707,6 +844,41 @@ mod tests { ); } + #[test] + fn app_tool_question_orders_tool_call_details_generically() { + 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(&serde_json::json!({ + "type": "object", + "required": ["projectId", "title"], + "properties": { + "body": { "type": "string" }, + "description": { "type": "string" }, + "projectId": { "type": "string" }, + "title": { "type": "string" } + } + })), + Some("Create Issue"), + Some("Linear"), + Some(&annotations(Some(false), Some(true), Some(true))), + true, + ); + + assert_eq!(question.header, "Approve Linear: Create Issue?"); + assert_eq!( + question.question, + "The Linear app wants to run the tool \"Create Issue\" (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 { diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs index 27d53229b6..b2106a37b6 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/layout.rs @@ -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, // Wrapped question text lines to render in the question area. pub(super) question_lines: Vec, 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, question_lines: &mut Vec, ) -> 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, question_lines: &mut Vec, ) -> 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, question_lines: &mut Vec, ) -> 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, diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs index 79b1229800..664bc0199a 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/mod.rs @@ -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 { self.current_question() - .map(|q| { - textwrap::wrap(&q.question, width.max(1) as usize) - .into_iter() - .map(|line| line.to_string()) - .collect::>() - }) + .map(|question| wrap_plain_text_lines(&question.question, width)) + .unwrap_or_default() + } + + pub(super) fn wrapped_header_lines(&self, width: u16) -> Vec { + 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 { + 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(|wrapped| wrapped.into_owned()) + .collect::>() + }) + .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, @@ -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(); diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs index eeda763579..2fc7a58cc5 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/render.rs +++ b/codex-rs/tui/src/bottom_pane/request_user_input/render.rs @@ -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, }, diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap index 872bfe1d0e..f4cac6477e 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_footer_wrap.snap @@ -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. diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap index 3ae7b9d624..26ac794dcd 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_freeform.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_hidden_options_footer.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_hidden_options_footer.snap index d643647f79..c746f5e2a0 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_hidden_options_footer.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_hidden_options_footer.snap @@ -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. diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_long_option_text.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_long_option_text.snap index ae5e53b477..d716e9c28e 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_long_option_text.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_long_option_text.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap index bb1c2a726a..77be473086 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_first.snap @@ -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. diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap index dbe06d4041..f507063e80 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multi_question_last.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multiline_approval.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multiline_approval.snap new file mode 100644 index 0000000000..025ec16268 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_multiline_approval.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap index c93576246d..a75e6e2e28 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options.snap @@ -4,6 +4,7 @@ expression: "render_snapshot(&overlay, area)" --- Question 1/1 (1 unanswered) + Area Choose an option. › 1. Option 1 First choice. diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap index a4540a2b26..97c09bb825 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_options_notes_visible.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap index 2e8d120e44..90f2df644d 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_scrolling_options.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap index c93576246d..c1408caf82 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_tight_height.snap @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap index 71d32c5abf..e2c089de22 100644 --- a/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap +++ b/codex-rs/tui/src/bottom_pane/request_user_input/snapshots/codex_tui__bottom_pane__request_user_input__tests__request_user_input_wrapped_options.snap @@ -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.