From 8ce2488dc24dcf8cfff9468118456a44b71c58b0 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 11 Jan 2026 19:45:06 -0800 Subject: [PATCH] queue and steer messages --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 311 +++++++++--------- codex-rs/tui/src/bottom_pane/footer.rs | 10 + codex-rs/tui/src/chatwidget.rs | 40 ++- codex-rs/tui/src/chatwidget/tests.rs | 4 +- .../tui/src/public_widgets/composer_input.rs | 4 +- .../tui2/src/bottom_pane/chat_composer.rs | 311 +++++++++--------- codex-rs/tui2/src/bottom_pane/footer.rs | 10 + codex-rs/tui2/src/chatwidget.rs | 40 ++- codex-rs/tui2/src/chatwidget/tests.rs | 4 +- .../tui2/src/public_widgets/composer_input.rs | 4 +- 10 files changed, 402 insertions(+), 336 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 2e7e2241b9..5cf8dbe30c 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), + Queued(String), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -101,6 +102,12 @@ enum PromptSelectionAction { Submit { text: String }, } +#[derive(Clone, Copy, Debug, PartialEq)] +enum SubmitMode { + Submit, + Queue, +} + pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -1198,161 +1205,171 @@ impl ChatComposer { } self.handle_input_basic(key_event) } + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => self.handle_submit(SubmitMode::Queue), KeyEvent { 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(SubmitMode::Submit), input => self.handle_input_basic(input), } } + fn handle_submit(&mut self, mode: SubmitMode) -> (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 = match mode { + SubmitMode::Submit => InputResult::Submitted(text), + SubmitMode::Queue => InputResult::Queued(text), + }; + (result, true) + } + fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { match self.paste_burst.flush_if_due(now) { FlushResult::Paste(pasted) => { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e29d14d8b..f3f97c559d 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -266,6 +266,7 @@ enum ShortcutId { Commands, ShellCommands, InsertNewline, + QueueMessage, FilePaths, PasteImage, ExternalEditor, @@ -372,6 +373,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::QueueMessage, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('k')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to queue message", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3fd7a891e..30d19ce35e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1630,25 +1630,29 @@ impl ChatWidget { self.request_redraw(); } } - _ => { - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - // If a task is running, queue the user input to be sent after the turn completes. - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; - self.queue_user_message(user_message); - } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} + _ => match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted(text) => { + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.submit_user_message(user_message); } - } + InputResult::Queued(text) => { + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::CommandWithArgs(cmd, args) => { + self.dispatch_command_with_args(cmd, args); + } + InputResult::None => {} + }, } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5d1ac36638..fda18f6448 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1057,7 +1057,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); // Queue the prompt while the task is running. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); } assert_eq!(chat.queued_user_messages.len(), 3); @@ -1079,7 +1079,7 @@ async fn streaming_final_answer_keeps_task_running_state() { chat.bottom_pane .set_composer_text("queued submission".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( diff --git a/codex-rs/tui/src/public_widgets/composer_input.rs b/codex-rs/tui/src/public_widgets/composer_input.rs index 2a80c087ee..232bdddf37 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::Queued(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..c89d7b8339 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), + Queued(String), Command(SlashCommand), CommandWithArgs(SlashCommand, String), None, @@ -104,6 +105,12 @@ enum PromptSelectionAction { Submit { text: String }, } +#[derive(Clone, Copy, Debug, PartialEq)] +enum SubmitMode { + Submit, + Queue, +} + pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -1132,161 +1139,171 @@ impl ChatComposer { } self.handle_input_basic(key_event) } + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => self.handle_submit(SubmitMode::Queue), KeyEvent { 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(SubmitMode::Submit), input => self.handle_input_basic(input), } } + fn handle_submit(&mut self, mode: SubmitMode) -> (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 = match mode { + SubmitMode::Submit => InputResult::Submitted(text), + SubmitMode::Queue => InputResult::Queued(text), + }; + (result, true) + } + fn handle_paste_burst_flush(&mut self, now: Instant) -> bool { match self.paste_burst.flush_if_due(now) { FlushResult::Paste(pasted) => { diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index 49b37633e6..fc8293542d 100644 --- a/codex-rs/tui2/src/bottom_pane/footer.rs +++ b/codex-rs/tui2/src/bottom_pane/footer.rs @@ -307,6 +307,7 @@ enum ShortcutId { Commands, ShellCommands, InsertNewline, + QueueMessage, FilePaths, PasteImage, EditPrevious, @@ -412,6 +413,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::QueueMessage, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('k')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to queue message", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index f5af1bfe67..d5f6a37f74 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1489,25 +1489,29 @@ impl ChatWidget { self.request_redraw(); } } - _ => { - match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - // If a task is running, queue the user input to be sent after the turn completes. - let user_message = UserMessage { - text, - image_paths: self.bottom_pane.take_recent_submission_images(), - }; - self.queue_user_message(user_message); - } - InputResult::Command(cmd) => { - self.dispatch_command(cmd); - } - InputResult::CommandWithArgs(cmd, args) => { - self.dispatch_command_with_args(cmd, args); - } - InputResult::None => {} + _ => match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted(text) => { + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.submit_user_message(user_message); } - } + InputResult::Queued(text) => { + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.queue_user_message(user_message); + } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } + InputResult::CommandWithArgs(cmd, args) => { + self.dispatch_command_with_args(cmd, args); + } + InputResult::None => {} + }, } } diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 9b7a825e0e..0027b0fd21 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -1008,7 +1008,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() { assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); // Queue the prompt while the task is running. - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); } assert_eq!(chat.queued_user_messages.len(), 3); @@ -1030,7 +1030,7 @@ async fn streaming_final_answer_keeps_task_running_state() { chat.bottom_pane .set_composer_text("queued submission".to_string()); - chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( diff --git a/codex-rs/tui2/src/public_widgets/composer_input.rs b/codex-rs/tui2/src/public_widgets/composer_input.rs index 2a80c087ee..232bdddf37 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::Queued(text) => { + ComposerAction::Submitted(text) + } _ => ComposerAction::None, }; self.drain_app_events();