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..aa76f4409c --- /dev/null +++ b/codex-rs/core/tests/suite/mid_turn_input.rs @@ -0,0 +1,180 @@ +#![cfg(not(target_os = "windows"))] + +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; +use core_test_support::responses::ResponsesRequest; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_local_shell_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; + +#[derive(Clone, Copy, Debug)] +enum MidTurnOp { + UserInput, + UserTurn, +} + +fn call_output(req: &ResponsesRequest, call_id: &str) -> String { + let raw = req.function_call_output(call_id); + assert_eq!( + raw.get("call_id").and_then(Value::as_str), + Some(call_id), + "mismatched call_id in function_call_output" + ); + let (content_opt, _success) = match req.function_call_output_content_and_success(call_id) { + Some(values) => values, + None => panic!("function_call_output present"), + }; + match content_opt { + Some(content) => content, + None => panic!("function_call_output content present"), + } +} + +async fn run_mid_turn_injection(op_kind: MidTurnOp) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_model("gpt-5"); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let call_id = "shell-tool-call"; + let command = vec!["/bin/sh", "-c", "sleep 2; echo finished"]; + let first_response = sse(vec![ + ev_response_created("resp-1"), + ev_local_shell_call(call_id, "completed", command), + ev_completed("resp-1"), + ]); + responses::mount_sse_once(&server, first_response).await; + + let second_response = sse(vec![ + ev_assistant_message("msg-1", "follow up"), + ev_completed("resp-2"), + ]); + let second_mock = responses::mount_sse_once(&server, second_response).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "first message".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model.clone(), + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::ExecCommandBegin(_))).await; + + let mid_turn_text = match op_kind { + MidTurnOp::UserInput => "mid-turn input", + MidTurnOp::UserTurn => "mid-turn turn", + }; + + match op_kind { + MidTurnOp::UserInput => { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: mid_turn_text.to_string(), + }], + final_output_json_schema: None, + }) + .await?; + } + MidTurnOp::UserTurn => { + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: mid_turn_text.to_string(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + } + } + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let req = second_mock.single_request(); + let output_text = call_output(&req, call_id); + let exec_output: Value = serde_json::from_str(&output_text)?; + let stdout = exec_output["output"].as_str().expect("stdout field"); + assert_eq!(stdout.trim(), "finished"); + + let user_messages = req.message_input_texts("user"); + assert_eq!( + user_messages, + vec!["first message".to_string(), mid_turn_text.to_string()] + ); + + let input = req.input(); + let call_idx = 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) + }) + .expect("function_call_output item"); + let user_idx = input + .iter() + .position(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user") + && item + .get("content") + .and_then(Value::as_array) + .map(|content| { + content.iter().any(|span| { + span.get("type").and_then(Value::as_str) == Some("input_text") + && span.get("text").and_then(Value::as_str) == Some(mid_turn_text) + }) + }) + .unwrap_or(false) + }) + .expect("mid-turn user message"); + assert!(call_idx < user_idx, "expected tool output before mid-turn input"); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_user_input_is_injected_after_tool_call() -> anyhow::Result<()> { + run_mid_turn_injection(MidTurnOp::UserInput).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_user_turn_is_injected_after_tool_call() -> anyhow::Result<()> { + run_mid_turn_injection(MidTurnOp::UserTurn).await +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2e7e2241b9..0562f7b5a5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -80,6 +80,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; #[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), + SubmittedImmediate(String), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -605,9 +606,15 @@ impl ChatComposer { } KeyEvent { code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, + modifiers, .. - } => { + } if matches!( + modifiers, + KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT + ) || modifiers == (KeyModifiers::SHIFT | KeyModifiers::ALT) => + { + let submit_immediately = + modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT); // If the current line starts with a custom prompt name and includes // positional args for a numeric-style template, expand and submit // immediately regardless of the popup selection. @@ -619,7 +626,7 @@ impl ChatComposer { expand_if_numeric_with_positional_args(prompt, first_line) { self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); + return (Self::submitted_result(expanded, submit_immediately), true); } if let Some(sel) = popup.selected_item() { @@ -637,7 +644,10 @@ impl ChatComposer { ) { PromptSelectionAction::Submit { text } => { self.textarea.set_text(""); - return (InputResult::Submitted(text), true); + return ( + Self::submitted_result(text, submit_immediately), + true, + ); } PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); @@ -1140,6 +1150,14 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + fn submitted_result(text: String, submit_immediately: bool) -> InputResult { + if submit_immediately { + InputResult::SubmittedImmediate(text) + } else { + InputResult::Submitted(text) + } + } + /// 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) { @@ -1200,9 +1218,15 @@ impl ChatComposer { } KeyEvent { code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, + modifiers, .. - } => { + } if matches!( + modifiers, + KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT + ) || modifiers == (KeyModifiers::SHIFT | KeyModifiers::ALT) => + { + let submit_immediately = + modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT); // 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 @@ -1347,7 +1371,7 @@ impl ChatComposer { 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::submitted_result(text, submit_immediately), true) } input => self.handle_input_basic(input), } @@ -2944,7 +2968,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } InputResult::None => panic!("expected Command result for '/init'"), @@ -3020,7 +3044,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3056,7 +3080,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } InputResult::None => panic!("expected Command result for '/mention'"), diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e29d14d8b..c31d1bd515 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::SendImmediate => 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, + SendImmediate, FilePaths, PasteImage, ExternalEditor, @@ -290,7 +294,6 @@ impl ShortcutBinding { enum DisplayCondition { Always, WhenShiftEnterHint, - WhenNotShiftEnterHint, WhenUnderWSL, } @@ -299,7 +302,6 @@ impl DisplayCondition { match self { DisplayCondition::Always => true, DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, - DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, } } @@ -359,18 +361,27 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ShortcutDescriptor { id: ShortcutId::InsertNewline, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('j')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for newline", + }, + ShortcutDescriptor { + id: ShortcutId::SendImmediate, bindings: &[ + ShortcutBinding { + key: key_hint::alt(KeyCode::Enter), + condition: DisplayCondition::Always, + }, ShortcutBinding { key: key_hint::shift(KeyCode::Enter), condition: DisplayCondition::WhenShiftEnterHint, }, - ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('j')), - condition: DisplayCondition::WhenNotShiftEnterHint, - }, ], prefix: "", - label: " for newline", + label: " to send immediately", }, ShortcutDescriptor { id: ShortcutId::FilePaths, 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..179dfacc12 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,6 @@ --- source: tui/src/bottom_pane/chat_composer.rs -assertion_line: 2151 +assertion_line: 2140 expression: terminal.backend() --- " " @@ -11,8 +11,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 " +" ctrl + j 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..fdae3d2c2e 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,11 @@ --- source: tui/src/bottom_pane/footer.rs -assertion_line: 455 +assertion_line: 466 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 " +" ctrl + j 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..600cad4ab3 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::SubmittedImmediate(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..04953c1f20 100644 --- a/codex-rs/tui/src/public_widgets/composer_input.rs +++ b/codex-rs/tui/src/public_widgets/composer_input.rs @@ -2,7 +2,7 @@ //! //! This exposes a minimal interface suitable for other crates (e.g., //! codex-cloud-tasks) to reuse the mature composer behavior: multi-line input, -//! paste heuristics, Enter-to-submit, and Shift+Enter for newline. +//! paste heuristics, Enter-to-submit, and Shift/Alt+Enter to send immediately. use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -36,7 +36,7 @@ impl ComposerInput { pub fn new() -> Self { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let sender = AppEventSender::new(tx.clone()); - // `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior. + // `enhanced_keys_supported=true` enables Shift+Enter submit behavior. let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false); Self { inner, _tx: tx, rx } } @@ -55,6 +55,7 @@ impl ComposerInput { 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::SubmittedImmediate(text) => ComposerAction::Submitted(text), _ => ComposerAction::None, }; self.drain_app_events(); diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 118dc8cd3d..dcfff4800d 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -83,6 +83,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; #[derive(Debug, PartialEq)] pub enum InputResult { Submitted(String), + SubmittedImmediate(String), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -539,9 +540,15 @@ impl ChatComposer { } KeyEvent { code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, + modifiers, .. - } => { + } if matches!( + modifiers, + KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT + ) || modifiers == (KeyModifiers::SHIFT | KeyModifiers::ALT) => + { + let submit_immediately = + modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT); // If the current line starts with a custom prompt name and includes // positional args for a numeric-style template, expand and submit // immediately regardless of the popup selection. @@ -553,7 +560,7 @@ impl ChatComposer { expand_if_numeric_with_positional_args(prompt, first_line) { self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); + return (Self::submitted_result(expanded, submit_immediately), true); } if let Some(sel) = popup.selected_item() { @@ -571,7 +578,10 @@ impl ChatComposer { ) { PromptSelectionAction::Submit { text } => { self.textarea.set_text(""); - return (InputResult::Submitted(text), true); + return ( + Self::submitted_result(text, submit_immediately), + true, + ); } PromptSelectionAction::Insert { text, cursor } => { let target = cursor.unwrap_or(text.len()); @@ -1074,6 +1084,14 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + fn submitted_result(text: String, submit_immediately: bool) -> InputResult { + if submit_immediately { + InputResult::SubmittedImmediate(text) + } else { + InputResult::Submitted(text) + } + } + /// 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) { @@ -1134,9 +1152,15 @@ impl ChatComposer { } KeyEvent { code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, + modifiers, .. - } => { + } if matches!( + modifiers, + KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT + ) || modifiers == (KeyModifiers::SHIFT | KeyModifiers::ALT) => + { + let submit_immediately = + modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT); // 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 @@ -1281,7 +1305,7 @@ impl ChatComposer { 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::submitted_result(text, submit_immediately), true) } input => self.handle_input_basic(input), } @@ -2891,7 +2915,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/init'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } InputResult::None => panic!("expected Command result for '/init'"), @@ -2967,7 +2991,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/diff'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } InputResult::None => panic!("expected Command result for '/diff'"), @@ -3003,7 +3027,7 @@ mod tests { InputResult::CommandWithArgs(_, _) => { panic!("expected command dispatch without args for '/mention'") } - InputResult::Submitted(text) => { + InputResult::Submitted(text) | InputResult::SubmittedImmediate(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } InputResult::None => panic!("expected Command result for '/mention'"), diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index 49b37633e6..c6d1653405 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::SendImmediate => 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, + SendImmediate, FilePaths, PasteImage, EditPrevious, @@ -330,7 +334,6 @@ impl ShortcutBinding { enum DisplayCondition { Always, WhenShiftEnterHint, - WhenNotShiftEnterHint, WhenUnderWSL, } @@ -339,7 +342,6 @@ impl DisplayCondition { match self { DisplayCondition::Always => true, DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, - DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, DisplayCondition::WhenUnderWSL => state.is_wsl, } } @@ -399,18 +401,27 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ShortcutDescriptor { id: ShortcutId::InsertNewline, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('j')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " for newline", + }, + ShortcutDescriptor { + id: ShortcutId::SendImmediate, bindings: &[ + ShortcutBinding { + key: key_hint::alt(KeyCode::Enter), + condition: DisplayCondition::Always, + }, ShortcutBinding { key: key_hint::shift(KeyCode::Enter), condition: DisplayCondition::WhenShiftEnterHint, }, - ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('j')), - condition: DisplayCondition::WhenNotShiftEnterHint, - }, ], prefix: "", - label: " for newline", + label: " to send immediately", }, ShortcutDescriptor { id: ShortcutId::FilePaths, 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..fa82157f3e 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,6 @@ --- source: tui2/src/bottom_pane/chat_composer.rs -assertion_line: 2093 +assertion_line: 2109 expression: terminal.backend() --- " " @@ -11,8 +11,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 " +" ctrl + j 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..ee3e5b8b30 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,10 @@ --- source: tui2/src/bottom_pane/footer.rs -assertion_line: 486 +assertion_line: 497 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 " +" ctrl + j 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..d153bf4f93 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::SubmittedImmediate(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..04953c1f20 100644 --- a/codex-rs/tui2/src/public_widgets/composer_input.rs +++ b/codex-rs/tui2/src/public_widgets/composer_input.rs @@ -2,7 +2,7 @@ //! //! This exposes a minimal interface suitable for other crates (e.g., //! codex-cloud-tasks) to reuse the mature composer behavior: multi-line input, -//! paste heuristics, Enter-to-submit, and Shift+Enter for newline. +//! paste heuristics, Enter-to-submit, and Shift/Alt+Enter to send immediately. use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -36,7 +36,7 @@ impl ComposerInput { pub fn new() -> Self { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); let sender = AppEventSender::new(tx.clone()); - // `enhanced_keys_supported=true` enables Shift+Enter newline hint/behavior. + // `enhanced_keys_supported=true` enables Shift+Enter submit behavior. let inner = ChatComposer::new(true, sender, true, "Compose new task".to_string(), false); Self { inner, _tx: tx, rx } } @@ -55,6 +55,7 @@ impl ComposerInput { 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::SubmittedImmediate(text) => ComposerAction::Submitted(text), _ => ComposerAction::None, }; self.drain_app_events();