mirror of
https://github.com/openai/codex.git
synced 2026-05-01 18:06:47 +00:00
Compare commits
4 Commits
codex-fix/
...
codex/impr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2876fcfdba | ||
|
|
020071a8ef | ||
|
|
d1ad017629 | ||
|
|
54690d39d2 |
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@ expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1 (1 unanswered)
|
||||
Area
|
||||
Choose an option.
|
||||
|
||||
› 1. Option 1 First choice.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user