diff --git a/codex-rs/core/tests/suite/mid_turn_input.rs b/codex-rs/core/tests/suite/mid_turn_input.rs new file mode 100644 index 0000000000..0539eafc6e --- /dev/null +++ b/codex-rs/core/tests/suite/mid_turn_input.rs @@ -0,0 +1,231 @@ +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::json; +use serde_json::Value; + +fn text_user_input(text: &str) -> Value { + json!({ + "type": "message", + "role": "user", + "content": [ { "type": "input_text", "text": text } ] + }) +} + +fn find_input_index(input: &[Value], expected: &Value, label: &str) -> usize { + input + .iter() + .position(|item| item == expected) + .unwrap_or_else(|| panic!("expected {label} in input")) +} + +fn find_function_call_output_index(input: &[Value], call_id: &str) -> usize { + input + .iter() + .position(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) + .unwrap_or_else(|| panic!("function_call_output {call_id} item not found in input")) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_user_input_is_inserted_after_tool_output() -> anyhow::Result<()> { + let call_id = "call-mid-turn-input"; + let first_text = "first message"; + let mid_text = "mid-turn input"; + + let args = json!({ + "command": "sleep 2; echo finished", + "timeout_ms": 10_000, + }) + .to_string(); + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell_command", &args), + ev_completed("resp-1"), + ]); + let follow_up_response = sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + + let server = start_mock_server().await; + let first_mock = mount_sse_once(&server, first_response).await; + let follow_up_mock = mount_sse_once(&server, follow_up_response).await; + let test = test_codex().with_model("gpt-5.1").build(&server).await?; + let codex = test.codex.clone(); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: first_text.to_string(), + }], + final_output_json_schema: None, + }) + .await?; + + wait_for_event(&codex, |ev| { + matches!(ev, EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id) + }) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: mid_text.to_string(), + }], + final_output_json_schema: None, + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let first_request = first_mock.single_request(); + let first_input = first_request.input(); + assert_eq!( + first_input, + vec![text_user_input(first_text)], + "expected first request input to match the initial message" + ); + + let follow_up = follow_up_mock.single_request(); + let input = follow_up.input(); + + let first_message = text_user_input(first_text); + let mid_message = text_user_input(mid_text); + let first_index = find_input_index(&input, &first_message, "first user message"); + let mid_index = find_input_index(&input, &mid_message, "mid-turn user message"); + assert!( + first_index < mid_index, + "expected first message before mid-turn message" + ); + + let output_index = find_function_call_output_index(&input, call_id); + assert!( + output_index < mid_index, + "expected tool output before mid-turn message" + ); + + let output = follow_up + .function_call_output_text(call_id) + .expect("missing function_call_output text"); + assert!( + output.contains("finished"), + "expected tool output to include \"finished\", got: {output}" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_user_turn_is_inserted_after_tool_output() -> anyhow::Result<()> { + let call_id = "call-mid-turn-turn"; + let first_text = "first message"; + let mid_text = "mid-turn turn"; + + let args = json!({ + "command": "sleep 2; echo finished", + "timeout_ms": 10_000, + }) + .to_string(); + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "shell_command", &args), + ev_completed("resp-1"), + ]); + let follow_up_response = sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + + let server = start_mock_server().await; + let first_mock = mount_sse_once(&server, first_response).await; + let follow_up_mock = mount_sse_once(&server, follow_up_response).await; + let test = test_codex().with_model("gpt-5.1").build(&server).await?; + let codex = test.codex.clone(); + let cwd = test.cwd_path().to_path_buf(); + let model = test.session_configured.model.clone(); + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: first_text.to_string(), + }], + final_output_json_schema: None, + }) + .await?; + + wait_for_event(&codex, |ev| { + matches!(ev, EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id) + }) + .await; + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: mid_text.to_string(), + }], + final_output_json_schema: None, + cwd, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let first_request = first_mock.single_request(); + let first_input = first_request.input(); + assert_eq!( + first_input, + vec![text_user_input(first_text)], + "expected first request input to match the initial message" + ); + + let follow_up = follow_up_mock.single_request(); + let input = follow_up.input(); + + let first_message = text_user_input(first_text); + let mid_message = text_user_input(mid_text); + let first_index = find_input_index(&input, &first_message, "first user message"); + let mid_index = find_input_index(&input, &mid_message, "mid-turn user message"); + assert!( + first_index < mid_index, + "expected first message before mid-turn message" + ); + + let output_index = find_function_call_output_index(&input, call_id); + assert!( + output_index < mid_index, + "expected tool output before mid-turn message" + ); + + let output = follow_up + .function_call_output_text(call_id) + .expect("missing function_call_output text"); + assert!( + output.contains("finished"), + "expected tool output to include \"finished\", got: {output}" + ); + + Ok(()) +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2e7e2241b9..5927b898c9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1,4 +1,5 @@ use crate::key_hint::has_ctrl_or_alt; +use crate::key_hint::is_altgr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -80,6 +81,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; #[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), + SubmittedImmediately(String), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -1140,6 +1142,168 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + fn is_send_immediately_key(&self, key_event: &KeyEvent) -> bool { + if key_event.kind != KeyEventKind::Press || key_event.code != KeyCode::Enter { + return false; + } + let modifiers = key_event.modifiers; + if modifiers.contains(KeyModifiers::ALT) && !is_altgr(modifiers) { + return true; + } + modifiers == KeyModifiers::SHIFT + } + + fn handle_submit(&mut self, immediate: bool) -> (InputResult, bool) { + // If the first line is a bare built-in slash command (no args), + // dispatch it even when the slash popup isn't visible. This preserves + // the workflow: type a prefix ("/di"), press Tab to complete to + // "/diff ", then press Enter to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter should still dispatch the command rather than submit + // literal text. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, rest)) = parse_slash_name(first_line) + && rest.is_empty() + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) + .find(|(n, _)| *n == name) + { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + // If we're in a paste-like burst capture, treat Enter as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat Enter as paste inside a slash-command context. + let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/'); + if self.paste_burst.is_active() && !in_slash_context { + let now = Instant::now(); + if self.paste_burst.append_newline_if_active(now) { + return (InputResult::None, true); + } + } + // If we have pending placeholder pastes, replace them in the textarea text + // and continue to the normal submission flow to handle slash commands. + if !self.pending_pastes.is_empty() { + let mut text = self.textarea.text().to_string(); + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.textarea.set_text(&text); + self.pending_pastes.clear(); + } + + // During a paste-like burst, treat Enter as a newline instead of submit. + let now = Instant::now(); + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + && !in_slash_context + { + self.textarea.insert_str("\n"); + self.paste_burst.extend_window(now); + return (InputResult::None, true); + } + let mut text = self.textarea.text().to_string(); + let original_input = text.clone(); + let input_starts_with_space = original_input.starts_with(' '); + self.textarea.set_text(""); + + // Replace all pending pastes in the text + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.pending_pastes.clear(); + + // If there is neither text nor attachments, suppress submission entirely. + let has_attachments = !self.attached_images.is_empty(); + text = text.trim().to_string(); + if let Some((name, _rest)) = parse_slash_name(&text) { + let treat_as_plain_text = input_starts_with_space || name.contains('/'); + if !treat_as_plain_text { + let is_builtin = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) + .any(|(command_name, _)| command_name == name); + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = name + .strip_prefix(&prompt_prefix) + .map(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) + .unwrap_or(false); + if !is_builtin && !is_known_prompt { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + } + } + + if !input_starts_with_space + && let Some((name, rest)) = parse_slash_name(&text) + && !rest.is_empty() + && !name.contains('/') + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .find(|(command_name, _)| *command_name == name) + && cmd == SlashCommand::Review + { + return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); + } + + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return (InputResult::None, true); + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + let result = if immediate { + InputResult::SubmittedImmediately(text) + } else { + InputResult::Submitted(text) + }; + (result, true) + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -1156,6 +1320,10 @@ impl ChatComposer { } else { self.footer_mode = reset_mode_after_activity(self.footer_mode); } + if self.is_task_running && self.is_send_immediately_key(&key_event) { + return self.handle_submit(true); + } + match key_event { KeyEvent { code: KeyCode::Char('d'), @@ -1202,153 +1370,7 @@ impl ChatComposer { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. - } => { - // If the first line is a bare built-in slash command (no args), - // dispatch it even when the slash popup isn't visible. This preserves - // the workflow: type a prefix ("/di"), press Tab to complete to - // "/diff ", then press Enter to run it. Tab moves the cursor beyond - // the '/name' token and our caret-based heuristic hides the popup, - // but Enter should still dispatch the command rather than submit - // literal text. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) - && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() - || *cmd != SlashCommand::ElevateSandbox - }) - .find(|(n, _)| *n == name) - { - self.textarea.set_text(""); - return (InputResult::Command(cmd), true); - } - // If we're in a paste-like burst capture, treat Enter as part of the burst - // and accumulate it rather than submitting or inserting immediately. - // Do not treat Enter as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); - if self.paste_burst.is_active() && !in_slash_context { - let now = Instant::now(); - if self.paste_burst.append_newline_if_active(now) { - return (InputResult::None, true); - } - } - // If we have pending placeholder pastes, replace them in the textarea text - // and continue to the normal submission flow to handle slash commands. - if !self.pending_pastes.is_empty() { - let mut text = self.textarea.text().to_string(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.textarea.set_text(&text); - self.pending_pastes.clear(); - } - - // During a paste-like burst, treat Enter as a newline instead of submit. - let now = Instant::now(); - if self - .paste_burst - .newline_should_insert_instead_of_submit(now) - && !in_slash_context - { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); - return (InputResult::None, true); - } - let mut text = self.textarea.text().to_string(); - let original_input = text.clone(); - let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); - - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.pending_pastes.clear(); - - // If there is neither text nor attachments, suppress submission entirely. - let has_attachments = !self.attached_images.is_empty(); - text = text.trim().to_string(); - if let Some((name, _rest)) = parse_slash_name(&text) { - let treat_as_plain_text = input_starts_with_space || name.contains('/'); - if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() - || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(command_name, _)| command_name == name); - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - let is_known_prompt = name - .strip_prefix(&prompt_prefix) - .map(|prompt_name| { - self.custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name) - }) - .unwrap_or(false); - if !is_builtin && !is_known_prompt { - let message = format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, None), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return (InputResult::None, true); - } - } - } - - if !input_starts_with_space - && let Some((name, rest)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review - { - return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); - } - - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return (InputResult::None, true); - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded; - } - if text.is_empty() && !has_attachments { - return (InputResult::None, true); - } - if !text.is_empty() { - self.history.record_local_submission(&text); - } - // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) - } + } => self.handle_submit(false), input => self.handle_input_basic(input), } } @@ -2490,7 +2512,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "1あ"), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, "1あ") + } _ => panic!("expected Submitted"), } } @@ -2629,7 +2653,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "hello"), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, "hello") + } _ => panic!("expected Submitted"), } } @@ -2689,7 +2715,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, large), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, large) + } _ => panic!("expected Submitted"), } assert!(composer.pending_pastes.is_empty()); @@ -2944,7 +2972,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } InputResult::None => panic!("expected Command result for '/init'"), @@ -3020,7 +3048,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3056,7 +3084,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } InputResult::None => panic!("expected Command result for '/mention'"), @@ -3363,7 +3391,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, "[Image #1] hi") + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -3386,7 +3416,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, "[Image #1]") + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -3759,7 +3791,7 @@ mod tests { // Verify the custom prompt was expanded with the large content as positional arg match result { - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { // The prompt should be expanded, with the large content replacing $1 assert_eq!( text, diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e29d14d8b..ea4bea6b45 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -161,6 +161,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut commands = Line::from(""); let mut shell_commands = Line::from(""); let mut newline = Line::from(""); + let mut send_immediately = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); let mut external_editor = Line::from(""); @@ -174,6 +175,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::Commands => commands = text, ShortcutId::ShellCommands => shell_commands = text, ShortcutId::InsertNewline => newline = text, + ShortcutId::SendImmediately => send_immediately = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, ShortcutId::ExternalEditor => external_editor = text, @@ -188,6 +190,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { commands, shell_commands, newline, + send_immediately, file_paths, paste_image, external_editor, @@ -266,6 +269,7 @@ enum ShortcutId { Commands, ShellCommands, InsertNewline, + SendImmediately, FilePaths, PasteImage, ExternalEditor, @@ -372,6 +376,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::SendImmediately, + bindings: &[ShortcutBinding { + key: key_hint::alt(KeyCode::Enter), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to send immediately", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index e25baa1112..5138bb464f 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -1,6 +1,5 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 2151 expression: terminal.backend() --- " " @@ -11,8 +10,9 @@ expression: terminal.backend() " " " " " " -" / for commands ! for shell commands " -" shift + enter for newline @ for file paths " -" ctrl + v to paste images ctrl + g to edit in external editor " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline ⌥ + enter to send immediately " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 6156a5b96a..633dfd9aba 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -1,10 +1,10 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 455 expression: terminal.backend() --- -" / for commands ! for shell commands " -" shift + enter for newline @ for file paths " -" ctrl + v to paste images ctrl + g to edit in external editor " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline ⌥ + enter to send immediately " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3fd7a891e..fbe66d8fab 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1640,6 +1640,13 @@ impl ChatWidget { }; self.queue_user_message(user_message); } + InputResult::SubmittedImmediately(text) => { + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.submit_user_message(user_message); + } InputResult::Command(cmd) => { self.dispatch_command(cmd); } diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 2a80c087ee..57fb576f38 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -54,7 +54,9 @@ impl ComposerInput { /// Feed a key event into the composer and return a high-level action. pub fn input(&mut self, key: KeyEvent) -> ComposerAction { let action = match self.inner.handle_key_event(key).0 { - InputResult::Submitted(text) => ComposerAction::Submitted(text), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + ComposerAction::Submitted(text) + } _ => ComposerAction::None, }; self.drain_app_events(); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index b5fee2e425..4203c2f77f 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -18,9 +18,6 @@ use crossterm::event::DisableFocusChange; use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableFocusChange; use crossterm::event::KeyEvent; -use crossterm::event::KeyboardEnhancementFlags; -use crossterm::event::PopKeyboardEnhancementFlags; -use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; @@ -59,21 +56,6 @@ pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; - // Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated. - // chat_composer.rs is using a keyboard event listener to enter for any modified keys - // to create a new line that require this. - // Some terminals (notably legacy Windows consoles) do not support - // keyboard enhancement flags. Attempt to enable them, but continue - // gracefully if unsupported. - let _ = execute!( - stdout(), - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES - | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS - ) - ); - let _ = execute!(stdout(), EnableFocusChange); Ok(()) } @@ -121,8 +103,6 @@ impl Command for DisableAlternateScroll { } fn restore_common(should_disable_raw_mode: bool) -> Result<()> { - // Pop may fail on platforms that didn't support the push; ignore errors. - let _ = execute!(stdout(), PopKeyboardEnhancementFlags); execute!(stdout(), DisableBracketedPaste)?; let _ = execute!(stdout(), DisableFocusChange); if should_disable_raw_mode { diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 118dc8cd3d..87d2d150ee 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1,6 +1,7 @@ use crate::key_hint; use crate::key_hint::KeyBinding; use crate::key_hint::has_ctrl_or_alt; +use crate::key_hint::is_altgr; use crate::transcript_copy_action::TranscriptCopyFeedback; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -83,6 +84,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; #[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), + SubmittedImmediately(String), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -1074,6 +1076,168 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + fn is_send_immediately_key(&self, key_event: &KeyEvent) -> bool { + if key_event.kind != KeyEventKind::Press || key_event.code != KeyCode::Enter { + return false; + } + let modifiers = key_event.modifiers; + if modifiers.contains(KeyModifiers::ALT) && !is_altgr(modifiers) { + return true; + } + modifiers == KeyModifiers::SHIFT + } + + fn handle_submit(&mut self, immediate: bool) -> (InputResult, bool) { + // If the first line is a bare built-in slash command (no args), + // dispatch it even when the slash popup isn't visible. This preserves + // the workflow: type a prefix ("/di"), press Tab to complete to + // "/diff ", then press Enter to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter should still dispatch the command rather than submit + // literal text. + let first_line = self.textarea.text().lines().next().unwrap_or(""); + if let Some((name, rest)) = parse_slash_name(first_line) + && rest.is_empty() + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) + .find(|(n, _)| *n == name) + { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + // If we're in a paste-like burst capture, treat Enter as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat Enter as paste inside a slash-command context. + let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) + || self + .textarea + .text() + .lines() + .next() + .unwrap_or("") + .starts_with('/'); + if self.paste_burst.is_active() && !in_slash_context { + let now = Instant::now(); + if self.paste_burst.append_newline_if_active(now) { + return (InputResult::None, true); + } + } + // If we have pending placeholder pastes, replace them in the textarea text + // and continue to the normal submission flow to handle slash commands. + if !self.pending_pastes.is_empty() { + let mut text = self.textarea.text().to_string(); + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.textarea.set_text(&text); + self.pending_pastes.clear(); + } + + // During a paste-like burst, treat Enter as a newline instead of submit. + let now = Instant::now(); + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + && !in_slash_context + { + self.textarea.insert_str("\n"); + self.paste_burst.extend_window(now); + return (InputResult::None, true); + } + let mut text = self.textarea.text().to_string(); + let original_input = text.clone(); + let input_starts_with_space = original_input.starts_with(' '); + self.textarea.set_text(""); + + // Replace all pending pastes in the text + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + self.pending_pastes.clear(); + + // If there is neither text nor attachments, suppress submission entirely. + let has_attachments = !self.attached_images.is_empty(); + text = text.trim().to_string(); + if let Some((name, _rest)) = parse_slash_name(&text) { + let treat_as_plain_text = input_starts_with_space || name.contains('/'); + if !treat_as_plain_text { + let is_builtin = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) + .any(|(command_name, _)| command_name == name); + let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); + let is_known_prompt = name + .strip_prefix(&prompt_prefix) + .map(|prompt_name| { + self.custom_prompts + .iter() + .any(|prompt| prompt.name == prompt_name) + }) + .unwrap_or(false); + if !is_builtin && !is_known_prompt { + let message = format!( + r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# + ); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event(message, None), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + } + } + + if !input_starts_with_space + && let Some((name, rest)) = parse_slash_name(&text) + && !rest.is_empty() + && !name.contains('/') + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .find(|(command_name, _)| *command_name == name) + && cmd == SlashCommand::Review + { + return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); + } + + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return (InputResult::None, true); + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + let result = if immediate { + InputResult::SubmittedImmediately(text) + } else { + InputResult::Submitted(text) + }; + (result, true) + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -1090,6 +1254,10 @@ impl ChatComposer { } else { self.footer_mode = reset_mode_after_activity(self.footer_mode); } + if self.is_task_running && self.is_send_immediately_key(&key_event) { + return self.handle_submit(true); + } + match key_event { KeyEvent { code: KeyCode::Char('d'), @@ -1136,153 +1304,7 @@ impl ChatComposer { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. - } => { - // If the first line is a bare built-in slash command (no args), - // dispatch it even when the slash popup isn't visible. This preserves - // the workflow: type a prefix ("/di"), press Tab to complete to - // "/diff ", then press Enter to run it. Tab moves the cursor beyond - // the '/name' token and our caret-based heuristic hides the popup, - // but Enter should still dispatch the command rather than submit - // literal text. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some((name, rest)) = parse_slash_name(first_line) - && rest.is_empty() - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() - || *cmd != SlashCommand::ElevateSandbox - }) - .find(|(n, _)| *n == name) - { - self.textarea.set_text(""); - return (InputResult::Command(cmd), true); - } - // If we're in a paste-like burst capture, treat Enter as part of the burst - // and accumulate it rather than submitting or inserting immediately. - // Do not treat Enter as paste inside a slash-command context. - let in_slash_context = matches!(self.active_popup, ActivePopup::Command(_)) - || self - .textarea - .text() - .lines() - .next() - .unwrap_or("") - .starts_with('/'); - if self.paste_burst.is_active() && !in_slash_context { - let now = Instant::now(); - if self.paste_burst.append_newline_if_active(now) { - return (InputResult::None, true); - } - } - // If we have pending placeholder pastes, replace them in the textarea text - // and continue to the normal submission flow to handle slash commands. - if !self.pending_pastes.is_empty() { - let mut text = self.textarea.text().to_string(); - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.textarea.set_text(&text); - self.pending_pastes.clear(); - } - - // During a paste-like burst, treat Enter as a newline instead of submit. - let now = Instant::now(); - if self - .paste_burst - .newline_should_insert_instead_of_submit(now) - && !in_slash_context - { - self.textarea.insert_str("\n"); - self.paste_burst.extend_window(now); - return (InputResult::None, true); - } - let mut text = self.textarea.text().to_string(); - let original_input = text.clone(); - let input_starts_with_space = original_input.starts_with(' '); - self.textarea.set_text(""); - - // Replace all pending pastes in the text - for (placeholder, actual) in &self.pending_pastes { - if text.contains(placeholder) { - text = text.replace(placeholder, actual); - } - } - self.pending_pastes.clear(); - - // If there is neither text nor attachments, suppress submission entirely. - let has_attachments = !self.attached_images.is_empty(); - text = text.trim().to_string(); - if let Some((name, _rest)) = parse_slash_name(&text) { - let treat_as_plain_text = input_starts_with_space || name.contains('/'); - if !treat_as_plain_text { - let is_builtin = built_in_slash_commands() - .into_iter() - .filter(|(_, cmd)| { - windows_degraded_sandbox_active() - || *cmd != SlashCommand::ElevateSandbox - }) - .any(|(command_name, _)| command_name == name); - let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); - let is_known_prompt = name - .strip_prefix(&prompt_prefix) - .map(|prompt_name| { - self.custom_prompts - .iter() - .any(|prompt| prompt.name == prompt_name) - }) - .unwrap_or(false); - if !is_builtin && !is_known_prompt { - let message = format!( - r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."# - ); - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event(message, None), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return (InputResult::None, true); - } - } - } - - if !input_starts_with_space - && let Some((name, rest)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review - { - return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); - } - - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { - Ok(expanded) => expanded, - Err(err) => { - self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_error_event(err.user_message()), - ))); - self.textarea.set_text(&original_input); - self.textarea.set_cursor(original_input.len()); - return (InputResult::None, true); - } - }; - if let Some(expanded) = expanded_prompt { - text = expanded; - } - if text.is_empty() && !has_attachments { - return (InputResult::None, true); - } - if !text.is_empty() { - self.history.record_local_submission(&text); - } - // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). - (InputResult::Submitted(text), true) - } + } => self.handle_submit(false), input => self.handle_input_basic(input), } } @@ -2460,7 +2482,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "1あ"), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, "1あ") + } _ => panic!("expected Submitted"), } } @@ -2576,7 +2600,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "hello"), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, "hello") + } _ => panic!("expected Submitted"), } } @@ -2636,7 +2662,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, large), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, large) + } _ => panic!("expected Submitted"), } assert!(composer.pending_pastes.is_empty()); @@ -2891,7 +2919,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } InputResult::None => panic!("expected Command result for '/init'"), @@ -2967,7 +2995,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3003,7 +3031,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } InputResult::None => panic!("expected Command result for '/mention'"), @@ -3310,7 +3338,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1] hi"), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, "[Image #1] hi") + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -3333,7 +3363,9 @@ mod tests { let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::Submitted(text) => assert_eq!(text, "[Image #1]"), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + assert_eq!(text, "[Image #1]") + } _ => panic!("expected Submitted"), } let imgs = composer.take_recent_submission_images(); @@ -3678,7 +3710,7 @@ mod tests { // Verify the custom prompt was expanded with the large content as positional arg match result { - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { // The prompt should be expanded, with the large content replacing $1 assert_eq!( text, diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index 49b37633e6..4bcf027a09 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -205,6 +205,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut commands = Line::from(""); let mut shell_commands = Line::from(""); let mut newline = Line::from(""); + let mut send_immediately = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); let mut edit_previous = Line::from(""); @@ -217,6 +218,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::Commands => commands = text, ShortcutId::ShellCommands => shell_commands = text, ShortcutId::InsertNewline => newline = text, + ShortcutId::SendImmediately => send_immediately = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, ShortcutId::EditPrevious => edit_previous = text, @@ -230,6 +232,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { commands, shell_commands, newline, + send_immediately, file_paths, paste_image, edit_previous, @@ -307,6 +310,7 @@ enum ShortcutId { Commands, ShellCommands, InsertNewline, + SendImmediately, FilePaths, PasteImage, EditPrevious, @@ -412,6 +416,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::SendImmediately, + bindings: &[ShortcutBinding { + key: key_hint::alt(KeyCode::Enter), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to send immediately", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 2bc29097f9..46a5d7526f 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -1,6 +1,5 @@ --- source: tui2/src/bottom_pane/chat_composer.rs -assertion_line: 2093 expression: terminal.backend() --- " " @@ -11,8 +10,8 @@ expression: terminal.backend() " " " " " " -" / for commands ! for shell commands " -" shift + enter for newline @ for file paths " -" ctrl + v to paste images esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline ⌥ + enter to send immediately " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 7863ca945f..c4ec66b9ba 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui2__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -1,10 +1,9 @@ --- source: tui2/src/bottom_pane/footer.rs -assertion_line: 486 expression: terminal.backend() --- -" / for commands ! for shell commands " -" shift + enter for newline @ for file paths " -" ctrl + v to paste images esc again to edit previous message " -" ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands ! for shell commands " +" shift + enter for newline ⌥ + enter to send immediately " +" @ for file paths ctrl + v to paste images " +" esc again to edit previous message ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index f5af1bfe67..4b82735998 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1499,6 +1499,13 @@ impl ChatWidget { }; self.queue_user_message(user_message); } + InputResult::SubmittedImmediately(text) => { + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.submit_user_message(user_message); + } InputResult::Command(cmd) => { self.dispatch_command(cmd); } diff --git a/codex-rs/tui2/src/public_widgets/composer_input.rs b/codex-rs/tui2/src/public_widgets/composer_input.rs index 2a80c087ee..57fb576f38 100644 --- a/codex-rs/tui2/src/public_widgets/composer_input.rs +++ b/codex-rs/tui2/src/public_widgets/composer_input.rs @@ -54,7 +54,9 @@ impl ComposerInput { /// Feed a key event into the composer and return a high-level action. pub fn input(&mut self, key: KeyEvent) -> ComposerAction { let action = match self.inner.handle_key_event(key).0 { - InputResult::Submitted(text) => ComposerAction::Submitted(text), + InputResult::Submitted(text) | InputResult::SubmittedImmediately(text) => { + ComposerAction::Submitted(text) + } _ => ComposerAction::None, }; self.drain_app_events(); diff --git a/codex-rs/tui2/src/tui.rs b/codex-rs/tui2/src/tui.rs index 1d6f129fa1..dc0595939d 100644 --- a/codex-rs/tui2/src/tui.rs +++ b/codex-rs/tui2/src/tui.rs @@ -18,9 +18,6 @@ use crossterm::event::EnableFocusChange; use crossterm::event::EnableMouseCapture; use crossterm::event::Event; use crossterm::event::KeyEvent; -use crossterm::event::KeyboardEnhancementFlags; -use crossterm::event::PopKeyboardEnhancementFlags; -use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; @@ -60,21 +57,6 @@ pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; - // Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated. - // chat_composer.rs is using a keyboard event listener to enter for any modified keys - // to create a new line that require this. - // Some terminals (notably legacy Windows consoles) do not support - // keyboard enhancement flags. Attempt to enable them, but continue - // gracefully if unsupported. - let _ = execute!( - stdout(), - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_EVENT_TYPES - | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS - ) - ); - let _ = execute!(stdout(), EnableFocusChange); // Enable application mouse mode so scroll events are delivered as // Mouse events instead of arrow keys. @@ -85,8 +67,6 @@ pub fn set_modes() -> Result<()> { /// Restore the terminal to its original state. /// Inverse of `set_modes`. pub fn restore() -> Result<()> { - // Pop may fail on platforms that didn't support the push; ignore errors. - let _ = execute!(stdout(), PopKeyboardEnhancementFlags); let _ = execute!(stdout(), DisableMouseCapture); execute!(stdout(), DisableBracketedPaste)?; let _ = execute!(stdout(), DisableFocusChange);