From cbca43d57a961cf37950895d39684413000bab6c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 12 Jan 2026 23:06:35 -0800 Subject: [PATCH] Send message by default mid turn. queue messages by tab (#9077) https://github.com/user-attachments/assets/03838730-4ddc-44df-a2c7-cb8ecda78660 --- codex-rs/core/src/codex.rs | 14 + codex-rs/core/src/features.rs | 12 + codex-rs/core/src/state/turn.rs | 4 + codex-rs/core/tests/common/streaming_sse.rs | 35 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/pending_input.rs | 143 ++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 420 ++++++++++++------ codex-rs/tui/src/bottom_pane/footer.rs | 13 + codex-rs/tui/src/bottom_pane/mod.rs | 4 + ...__tests__footer_mode_shortcut_overlay.snap | 12 +- ...tests__footer_shortcuts_shift_and_esc.snap | 12 +- codex-rs/tui/src/chatwidget.rs | 35 +- codex-rs/tui/src/chatwidget/tests.rs | 7 +- codex-rs/tui/tooltips.txt | 1 + .../tui2/src/bottom_pane/chat_composer.rs | 398 +++++++++++------ codex-rs/tui2/src/bottom_pane/footer.rs | 13 + codex-rs/tui2/src/bottom_pane/mod.rs | 4 + ...__tests__footer_mode_shortcut_overlay.snap | 11 +- ...tests__footer_shortcuts_shift_and_esc.snap | 11 +- ...__tests__footer_mode_shortcut_overlay.snap | 11 +- ...tests__footer_shortcuts_shift_and_esc.snap | 10 +- codex-rs/tui2/src/chatwidget.rs | 38 +- codex-rs/tui2/src/chatwidget/tests.rs | 7 +- codex-rs/tui2/tooltips.txt | 1 + 24 files changed, 875 insertions(+), 342 deletions(-) create mode 100644 codex-rs/core/tests/suite/pending_input.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3ecb82ec5..019e57740 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1608,6 +1608,17 @@ impl Session { } } + pub async fn has_pending_input(&self) -> bool { + let active = self.active_turn.lock().await; + match active.as_ref() { + Some(at) => { + let ts = at.turn_state.lock().await; + ts.has_pending_input() + } + None => false, + } + } + pub async fn list_resources( &self, server: &str, @@ -2904,6 +2915,9 @@ async fn try_run_turn( .await; should_emit_turn_diff = true; + needs_follow_up |= sess.has_pending_input().await; + error!("needs_follow_up: {needs_follow_up}"); + break Ok(TurnRunResult { needs_follow_up, last_agent_message, diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 8c1c597ee..baed1f6de 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -96,6 +96,8 @@ pub enum Feature { EnableRequestCompression, /// Enable collab tools. Collab, + /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. + Steer, } impl Feature { @@ -420,4 +422,14 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Experimental, default_enabled: false, }, + FeatureSpec { + id: Feature::Steer, + key: "steer", + stage: Stage::Beta { + name: "Steer conversation", + menu_description: "Enter submits immediately; Tab queues messages when a task is running.", + announcement: "NEW! Try Steer mode: Enter submits immediately, Tab queues. Enable in /experimental!", + }, + default_enabled: false, + }, ]; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index e2fff0554..ddc9efafe 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -104,6 +104,10 @@ impl TurnState { ret } } + + pub(crate) fn has_pending_input(&self) -> bool { + !self.pending_input.is_empty() + } } impl ActiveTurn { diff --git a/codex-rs/core/tests/common/streaming_sse.rs b/codex-rs/core/tests/common/streaming_sse.rs index 4f1b3673b..db34a2c17 100644 --- a/codex-rs/core/tests/common/streaming_sse.rs +++ b/codex-rs/core/tests/common/streaming_sse.rs @@ -19,6 +19,7 @@ pub struct StreamingSseChunk { /// Minimal streaming SSE server for tests that need gated per-chunk delivery. pub struct StreamingSseServer { uri: String, + requests: Arc>>>, shutdown: oneshot::Sender<()>, task: tokio::task::JoinHandle<()>, } @@ -28,6 +29,10 @@ impl StreamingSseServer { &self.uri } + pub async fn requests(&self) -> Vec> { + self.requests.lock().await.clone() + } + pub async fn shutdown(self) { let _ = self.shutdown.send(()); let _ = self.task.await; @@ -61,6 +66,8 @@ pub async fn start_streaming_sse_server( responses: VecDeque::from(responses), completions: VecDeque::from(completion_senders), })); + let requests = Arc::new(TokioMutex::new(Vec::new())); + let requests_for_task = Arc::clone(&requests); let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); let task = tokio::spawn(async move { @@ -70,6 +77,7 @@ pub async fn start_streaming_sse_server( accept_res = listener.accept() => { let (mut stream, _) = accept_res.expect("accept streaming SSE connection"); let state = Arc::clone(&state); + let requests = Arc::clone(&requests_for_task); tokio::spawn(async move { let (request, body_prefix) = read_http_request(&mut stream).await; let Some((method, path)) = parse_request_line(&request) else { @@ -78,7 +86,7 @@ pub async fn start_streaming_sse_server( }; if method == "GET" && path == "/v1/models" { - if drain_request_body(&mut stream, &request, body_prefix) + if read_request_body(&mut stream, &request, body_prefix) .await .is_err() { @@ -95,13 +103,16 @@ pub async fn start_streaming_sse_server( } if method == "POST" && path == "/v1/responses" { - if drain_request_body(&mut stream, &request, body_prefix) + let body = match read_request_body(&mut stream, &request, body_prefix) .await - .is_err() { - let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await; - return; - } + Ok(body) => body, + Err(_) => { + let _ = write_http_response(&mut stream, 400, "bad request", "text/plain").await; + return; + } + }; + requests.lock().await.push(body); let Some((chunks, completion)) = take_next_stream(&state).await else { let _ = write_http_response(&mut stream, 500, "no responses queued", "text/plain").await; return; @@ -137,6 +148,7 @@ pub async fn start_streaming_sse_server( ( StreamingSseServer { uri, + requests, shutdown: shutdown_tx, task, }, @@ -202,13 +214,13 @@ fn content_length(headers: &str) -> Option { }) } -async fn drain_request_body( +async fn read_request_body( stream: &mut tokio::net::TcpStream, headers: &str, mut body_prefix: Vec, -) -> std::io::Result<()> { +) -> std::io::Result> { let Some(content_len) = content_length(headers) else { - return Ok(()); + return Ok(body_prefix); }; if body_prefix.len() > content_len { @@ -217,12 +229,13 @@ async fn drain_request_body( let remaining = content_len.saturating_sub(body_prefix.len()); if remaining == 0 { - return Ok(()); + return Ok(body_prefix); } let mut rest = vec![0u8; remaining]; stream.read_exact(&mut rest).await?; - Ok(()) + body_prefix.extend_from_slice(&rest); + Ok(body_prefix) } async fn write_sse_headers(stream: &mut tokio::net::TcpStream) -> std::io::Result<()> { diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 1838df3ca..c75cb5407 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -42,6 +42,7 @@ mod model_overrides; mod model_tools; mod models_etag_responses; mod otel; +mod pending_input; mod permissions_messages; mod prompt_caching; mod quota_exceeded; diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs new file mode 100644 index 000000000..3fbf79fa0 --- /dev/null +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -0,0 +1,143 @@ +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::responses; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_message_item_added; +use core_test_support::responses::ev_output_text_delta; +use core_test_support::responses::ev_response_created; +use core_test_support::streaming_sse::StreamingSseChunk; +use core_test_support::streaming_sse::start_streaming_sse_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use tokio::sync::oneshot; + +fn ev_message_item_done(id: &str, text: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "id": id, + "content": [{"type": "output_text", "text": text}] + } + }) +} + +fn sse_event(event: Value) -> String { + responses::sse(vec![event]) +} + +fn message_input_texts(body: &Value, role: &str) -> Vec { + body.get("input") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("message")) + .filter(|item| item.get("role").and_then(Value::as_str) == Some(role)) + .filter_map(|item| item.get("content").and_then(Value::as_array)) + .flatten() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text")) + .filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned)) + .collect() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn injected_user_input_triggers_follow_up_request_with_deltas() { + let (gate_completed_tx, gate_completed_rx) = oneshot::channel(); + + let first_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(ev_response_created("resp-1")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_message_item_added("msg-1", "")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_output_text_delta("first ")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_output_text_delta("turn")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_message_item_done("msg-1", "first turn")), + }, + StreamingSseChunk { + gate: Some(gate_completed_rx), + body: sse_event(ev_completed("resp-1")), + }, + ]; + + let second_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(ev_response_created("resp-2")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(ev_completed("resp-2")), + }, + ]; + + let (server, _completions) = + start_streaming_sse_server(vec![first_chunks, second_chunks]).await; + + let codex = test_codex() + .with_model("gpt-5.1") + .build_with_streaming_server(&server) + .await + .unwrap() + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first prompt".into(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |event| { + matches!(event, EventMsg::AgentMessageContentDelta(_)) + }) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "second prompt".into(), + }], + final_output_json_schema: None, + }) + .await + .unwrap(); + + let _ = gate_completed_tx.send(()); + + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let requests = server.requests().await; + assert_eq!(requests.len(), 2); + + let first_body: Value = serde_json::from_slice(&requests[0]).expect("parse first request"); + let second_body: Value = serde_json::from_slice(&requests[1]).expect("parse second request"); + + let first_texts = message_input_texts(&first_body, "user"); + assert!(first_texts.iter().any(|text| text == "first prompt")); + assert!(!first_texts.iter().any(|text| text == "second prompt")); + + let second_texts = message_input_texts(&second_body, "user"); + assert!(second_texts.iter().any(|text| text == "first prompt")); + assert!(second_texts.iter().any(|text| text == "second prompt")); + + server.shutdown().await; +} diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bb50ac0af..ce277154c 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, @@ -132,6 +133,7 @@ pub(crate) struct ChatComposer { context_window_used_tokens: Option, skills: Option>, dismissed_skill_popup_token: Option, + steer_enabled: bool, } /// Popup state – at most one can be visible at any time. @@ -182,6 +184,7 @@ impl ChatComposer { context_window_used_tokens: None, skills: None, dismissed_skill_popup_token: None, + steer_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -192,6 +195,10 @@ impl ChatComposer { self.skills = skills; } + pub fn set_steer_enabled(&mut self, enabled: bool) { + self.steer_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -1140,6 +1147,199 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. + fn prepare_submission_text(&mut self) -> Option { + // 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(); + } + + 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 None; + } + } + } + + 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 None; + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return None; + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + Some(text) + } + + /// Common logic for handling message submission/queuing. + /// Returns the appropriate InputResult based on `should_queue`. + fn handle_submission(&mut self, should_queue: 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/Ctrl+Shift+Q to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit + // literal text. + if let Some(result) = self.try_dispatch_bare_slash_command() { + return (result, true); + } + + // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat 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); + } + } + + // During a paste-like burst, treat Enter/Ctrl+Shift+Q 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 original_input = self.textarea.text().to_string(); + if let Some(result) = self.try_dispatch_slash_command_with_args() { + return (result, true); + } + + if let Some(text) = self.prepare_submission_text() { + if should_queue { + (InputResult::Queued(text), true) + } else { + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + (InputResult::Submitted(text), true) + } + } else { + // Restore text if submission was suppressed + self.textarea.set_text(&original_input); + (InputResult::None, true) + } + } + + /// Check if the first line is a bare slash command (no args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_bare_slash_command(&mut self) -> Option { + 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(""); + Some(InputResult::Command(cmd)) + } else { + None + } + } + + /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_slash_command_with_args(&mut self) -> Option { + let original_input = self.textarea.text().to_string(); + let input_starts_with_space = original_input.starts_with(' '); + + if !input_starts_with_space { + let text = self.textarea.text().to_string(); + if 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 + { + self.textarea.set_text(""); + return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); + } + } + None + } + /// 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) { @@ -1198,156 +1398,19 @@ impl ChatComposer { } self.handle_input_basic(key_event) } + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } => self.handle_submission(true), 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) + let should_queue = !self.steer_enabled; + self.handle_submission(should_queue) } input => self.handle_input_basic(input), } @@ -2208,6 +2271,14 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); composer.set_text_content("draft text".to_string()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); @@ -2234,6 +2305,13 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let (result, needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); @@ -2274,6 +2352,10 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); // Force an active paste burst so this test doesn't depend on tight timing. composer @@ -2307,6 +2389,10 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); @@ -2481,6 +2567,10 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); @@ -2510,6 +2600,10 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('あ'), KeyModifiers::NONE)); @@ -2581,6 +2675,9 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); // Force an active burst so this test doesn't depend on tight timing. composer @@ -2620,6 +2717,9 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); @@ -2649,6 +2749,9 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); + composer.set_steer_enabled(true); // Ensure composer is empty and press Enter. assert!(composer.textarea.text().is_empty()); @@ -2676,6 +2779,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); let needs_redraw = composer.handle_paste(large.clone()); @@ -2711,6 +2815,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.handle_paste(large); assert_eq!(composer.pending_pastes.len(), 1); @@ -2807,6 +2912,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Type "/mo" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'm', 'o']); @@ -2835,6 +2941,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); type_chars_humanlike(&mut composer, &['/', 'm', 'o']); match &composer.active_popup { @@ -2866,6 +2973,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Type "/res" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); @@ -2959,6 +3067,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch, but composer queued literal text") + } InputResult::None => panic!("expected Command result for '/init'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -3035,6 +3146,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch after Tab completion, got literal queue") + } InputResult::None => panic!("expected Command result for '/diff'"), } assert!(composer.textarea.is_empty()); @@ -3071,6 +3185,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch, but composer queued literal text") + } InputResult::None => panic!("expected Command result for '/mention'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -3093,6 +3210,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Define test cases: (paste content, is_large) let test_cases = [ @@ -3369,6 +3487,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let path = PathBuf::from("/tmp/image1.png"); composer.attach_image(path.clone()); composer.handle_paste(" hi".into()); @@ -3393,6 +3512,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let path = PathBuf::from("/tmp/image2.png"); composer.attach_image(path.clone()); let (result, _) = @@ -3628,6 +3748,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Inject prompts as if received via event. composer.set_custom_prompts(vec![CustomPrompt { @@ -3664,6 +3785,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3698,6 +3820,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3736,6 +3859,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Create a custom prompt with positional args (no named args like $USER) composer.set_custom_prompts(vec![CustomPrompt { @@ -3800,6 +3924,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer .textarea @@ -3836,6 +3961,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.textarea.set_text(" /this-looks-like-a-command"); @@ -3969,6 +4095,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -4006,6 +4133,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "elegant".to_string(), @@ -4073,6 +4201,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "price".to_string(), @@ -4110,6 +4239,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "repeat".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 2e29d14d8..4afd7cf36 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 queue_message_tab = 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::QueueMessageTab => queue_message_tab = 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, + queue_message_tab, file_paths, paste_image, external_editor, @@ -266,6 +269,7 @@ enum ShortcutId { Commands, ShellCommands, InsertNewline, + QueueMessageTab, FilePaths, PasteImage, ExternalEditor, @@ -372,6 +376,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::QueueMessageTab, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Tab), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to queue message", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f795ea64f..f505b0271 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -145,6 +145,10 @@ impl BottomPane { self.request_redraw(); } + pub fn set_steer_enabled(&mut self, enabled: bool) { + self.composer.set_steer_enabled(enabled); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } 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 e25baa111..8486a9ec6 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 tab to queue message " +" @ 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 6156a5b96..c1f00d443 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 tab to queue message " +" @ 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 34c0bba78..3cfd02074 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1543,6 +1543,9 @@ impl ChatWidget { }; widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } @@ -1630,6 +1633,9 @@ impl ChatWidget { }; widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } @@ -1695,7 +1701,19 @@ impl ChatWidget { _ => { 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. + // Enter always sends messages immediately (bypasses queue check) + // Clear any reasoning status header when submitting a new message + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.submit_user_message(user_message); + } + InputResult::Queued(text) => { + // Tab queues the message if a task is running, otherwise submits immediately let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), @@ -2109,6 +2127,18 @@ impl ChatWidget { if !text.is_empty() { self.add_to_history(history_cell::new_user_prompt(text)); } + + // If steer is enabled and a task is running, show hint about queuing with Tab + if self.config.features.enabled(Feature::Steer) && self.bottom_pane.is_task_running() { + use crate::key_hint; + use ratatui::text::Line; + let hint_line = Line::from(vec![ + "You can queue messages by pressing ".dim(), + key_hint::plain(KeyCode::Tab).into(), + ]); + self.add_to_history(history_cell::PlainHistoryCell::new(vec![hint_line])); + } + self.needs_final_message_separator = false; } @@ -3634,6 +3664,9 @@ impl ChatWidget { } else { self.config.features.disable(feature); } + if feature == Feature::Steer { + self.bottom_pane.set_steer_enabled(enabled); + } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3f1c36ddd..f51392fa9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -372,7 +372,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } - let bottom = BottomPane::new(BottomPaneParams { + let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), has_input_focus: true, @@ -382,6 +382,7 @@ async fn make_chatwidget_manual( animations_enabled: cfg.animations, skills: None, }); + bottom.set_steer_enabled(true); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); let widget = ChatWidget { @@ -1058,7 +1059,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::Tab, KeyModifiers::NONE)); } assert_eq!(chat.queued_user_messages.len(), 3); @@ -1080,7 +1081,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::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 33e891611..0fdb0ca73 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -6,5 +6,6 @@ You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands and saved prompts. You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts When the composer is empty, press Esc to step back and edit your last message; Enter confirms. +Press Tab to queue a message instead of sending it immediately; Enter always sends immediately. Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume` diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 118dc8cd3..82d2e9854 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, @@ -140,6 +141,7 @@ pub(crate) struct ChatComposer { transcript_copy_feedback: Option, skills: Option>, dismissed_skill_popup_token: Option, + steer_enabled: bool, } /// Popup state – at most one can be visible at any time. @@ -195,6 +197,7 @@ impl ChatComposer { transcript_copy_feedback: None, skills: None, dismissed_skill_popup_token: None, + steer_enabled: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -205,6 +208,10 @@ impl ChatComposer { self.skills = skills; } + pub fn set_steer_enabled(&mut self, enabled: bool) { + self.steer_enabled = enabled; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -1074,6 +1081,199 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + /// Prepare text for submission/queuing. Returns None if submission should be suppressed. + fn prepare_submission_text(&mut self) -> Option { + // 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(); + } + + 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 None; + } + } + } + + 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 None; + } + }; + if let Some(expanded) = expanded_prompt { + text = expanded; + } + if text.is_empty() && !has_attachments { + return None; + } + if !text.is_empty() { + self.history.record_local_submission(&text); + } + Some(text) + } + + /// Common logic for handling message submission/queuing. + /// Returns the appropriate InputResult based on `should_queue`. + fn handle_submission(&mut self, should_queue: 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/Ctrl+Shift+Q to run it. Tab moves the cursor beyond + // the '/name' token and our caret-based heuristic hides the popup, + // but Enter/Ctrl+Shift+Q should still dispatch the command rather than submit + // literal text. + if let Some(result) = self.try_dispatch_bare_slash_command() { + return (result, true); + } + + // If we're in a paste-like burst capture, treat Enter/Ctrl+Shift+Q as part of the burst + // and accumulate it rather than submitting or inserting immediately. + // Do not treat 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); + } + } + + // During a paste-like burst, treat Enter/Ctrl+Shift+Q 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 original_input = self.textarea.text().to_string(); + if let Some(result) = self.try_dispatch_slash_command_with_args() { + return (result, true); + } + + if let Some(text) = self.prepare_submission_text() { + if should_queue { + (InputResult::Queued(text), true) + } else { + // Do not clear attached_images here; ChatWidget drains them via take_recent_submission_images(). + (InputResult::Submitted(text), true) + } + } else { + // Restore text if submission was suppressed + self.textarea.set_text(&original_input); + (InputResult::None, true) + } + } + + /// Check if the first line is a bare slash command (no args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_bare_slash_command(&mut self) -> Option { + 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(""); + Some(InputResult::Command(cmd)) + } else { + None + } + } + + /// Check if the input is a slash command with args (e.g., /review args) and dispatch it. + /// Returns Some(InputResult) if a command was dispatched, None otherwise. + fn try_dispatch_slash_command_with_args(&mut self) -> Option { + let original_input = self.textarea.text().to_string(); + let input_starts_with_space = original_input.starts_with(' '); + + if !input_starts_with_space { + let text = self.textarea.text().to_string(); + if 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 + { + self.textarea.set_text(""); + return Some(InputResult::CommandWithArgs(cmd, rest.to_string())); + } + } + None + } + /// 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) { @@ -1132,156 +1332,28 @@ impl ChatComposer { } self.handle_input_basic(key_event) } + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } => self.handle_submission(true), + KeyEvent { + code: KeyCode::Char('k'), + modifiers: KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + // Tab queues the message instead of submitting immediately + self.handle_submission(true) + } 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) + let should_queue = !self.steer_enabled; + self.handle_submission(should_queue) } input => self.handle_input_basic(input), } @@ -2177,6 +2249,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_text_content("draft text".to_string()); assert_eq!(composer.clear_for_ctrl_c(), Some("draft text".to_string())); @@ -2203,6 +2276,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let (result, needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); @@ -2243,6 +2317,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Force an active paste burst so this test doesn't depend on tight timing. composer @@ -2277,6 +2352,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay); @@ -2451,6 +2527,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); assert!(composer.is_in_paste_burst()); @@ -2528,6 +2605,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Force an active burst so this test doesn't depend on tight timing. composer @@ -2567,6 +2645,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let needs_redraw = composer.handle_paste("hello".to_string()); assert!(needs_redraw); @@ -2596,6 +2675,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Ensure composer is empty and press Enter. assert!(composer.textarea.text().is_empty()); @@ -2623,6 +2703,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10); let needs_redraw = composer.handle_paste(large.clone()); @@ -2658,6 +2739,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.handle_paste(large); assert_eq!(composer.pending_pastes.len(), 1); @@ -2742,6 +2824,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Type "/mo" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'm', 'o']); @@ -2770,6 +2853,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); type_chars_humanlike(&mut composer, &['/', 'm', 'o']); match &composer.active_popup { @@ -2801,6 +2885,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Type "/res" humanlike so paste-burst doesn’t interfere. type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); @@ -2826,6 +2911,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); match &composer.active_popup { @@ -2894,6 +2980,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch, but composer queued literal text") + } InputResult::None => panic!("expected Command result for '/init'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -2970,6 +3059,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch after Tab completion, got literal queue") + } InputResult::None => panic!("expected Command result for '/diff'"), } assert!(composer.textarea.is_empty()); @@ -3006,6 +3098,9 @@ mod tests { InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::Queued(_) => { + panic!("expected command dispatch, but composer queued literal text") + } InputResult::None => panic!("expected Command result for '/mention'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); @@ -3028,6 +3123,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Define test cases: (paste content, is_large) let test_cases = [ @@ -3304,6 +3400,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let path = PathBuf::from("/tmp/image1.png"); composer.attach_image(path.clone()); composer.handle_paste(" hi".into()); @@ -3328,6 +3425,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); let path = PathBuf::from("/tmp/image2.png"); composer.attach_image(path.clone()); let (result, _) = @@ -3535,6 +3633,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Inject prompts as if received via event. composer.set_custom_prompts(vec![CustomPrompt { @@ -3571,6 +3670,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3605,6 +3705,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3643,6 +3744,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); // Create a custom prompt with positional args (no named args like $USER) composer.set_custom_prompts(vec![CustomPrompt { @@ -3707,6 +3809,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer .textarea @@ -3743,6 +3846,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.textarea.set_text(" /this-looks-like-a-command"); @@ -3876,6 +3980,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "my-prompt".to_string(), @@ -3913,6 +4018,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "elegant".to_string(), @@ -3980,6 +4086,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "price".to_string(), @@ -4017,6 +4124,7 @@ mod tests { "Ask Codex to do anything".to_string(), false, ); + composer.set_steer_enabled(true); composer.set_custom_prompts(vec![CustomPrompt { name: "repeat".to_string(), diff --git a/codex-rs/tui2/src/bottom_pane/footer.rs b/codex-rs/tui2/src/bottom_pane/footer.rs index 49b37633e..4db04464d 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 queue_message_tab = 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::QueueMessageTab => queue_message_tab = 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, + queue_message_tab, file_paths, paste_image, edit_previous, @@ -307,6 +310,7 @@ enum ShortcutId { Commands, ShellCommands, InsertNewline, + QueueMessageTab, FilePaths, PasteImage, EditPrevious, @@ -412,6 +416,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " for newline", }, + ShortcutDescriptor { + id: ShortcutId::QueueMessageTab, + bindings: &[ShortcutBinding { + key: key_hint::plain(KeyCode::Tab), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to queue message", + }, ShortcutDescriptor { id: ShortcutId::FilePaths, bindings: &[ShortcutBinding { diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 887fb3e64..bbf5e8849 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -137,6 +137,10 @@ impl BottomPane { self.request_redraw(); } + pub fn set_steer_enabled(&mut self, enabled: bool) { + self.composer.set_steer_enabled(enabled); + } + pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> { self.status.as_ref() } 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 2bc29097f..be9e62f40 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 tab to queue message " +" @ 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 7863ca945..ba860d1f4 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 tab to queue message " +" @ 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_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 3b6782d06..027244002 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -9,8 +9,9 @@ expression: terminal.backend() " " " " " " -" " -" / for 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 tab to queue message " +" @ 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/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 264515a6c..c1f00d443 100644 --- a/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui2/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,7 +2,9 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for 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 tab to queue message " +" @ 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/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index dd0bacf23..225d1b024 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1401,6 +1401,9 @@ impl ChatWidget { }; widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } @@ -1486,6 +1489,9 @@ impl ChatWidget { }; widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); widget } @@ -1551,7 +1557,19 @@ impl ChatWidget { _ => { 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. + // Enter always sends messages immediately (bypasses queue check) + // Clear any reasoning status header when submitting a new message + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + let user_message = UserMessage { + text, + image_paths: self.bottom_pane.take_recent_submission_images(), + }; + self.submit_user_message(user_message); + } + InputResult::Queued(text) => { + // Tab queues the message if a task is running, otherwise submits immediately let user_message = UserMessage { text, image_paths: self.bottom_pane.take_recent_submission_images(), @@ -1837,6 +1855,7 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } + #[allow(dead_code)] // Used in tests fn queue_user_message(&mut self, user_message: UserMessage) { if self.bottom_pane.is_task_running() { self.queued_user_messages.push_back(user_message); @@ -2111,9 +2130,23 @@ impl ChatWidget { fn on_user_message_event(&mut self, event: UserMessageEvent) { let message = event.message.trim(); + // Only show the text portion in conversation history. if !message.is_empty() { self.add_to_history(history_cell::new_user_prompt(message.to_string())); } + + // If steer is enabled and a task is running, show hint about queuing with Tab + if self.config.features.enabled(Feature::Steer) && self.bottom_pane.is_task_running() { + use crate::key_hint; + use ratatui::text::Line; + let hint_line = Line::from(vec![ + "You can queue messages by pressing ".dim(), + key_hint::plain(KeyCode::Tab).into(), + ]); + self.add_to_history(history_cell::PlainHistoryCell::new(vec![hint_line])); + } + + self.needs_final_message_separator = false; } fn request_exit(&self) { @@ -3362,6 +3395,9 @@ impl ChatWidget { } else { self.config.features.disable(feature); } + if feature == Feature::Steer { + self.bottom_pane.set_steer_enabled(enabled); + } } pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index f5134d13b..32dce6b64 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -361,7 +361,7 @@ async fn make_chatwidget_manual( if let Some(model) = model_override { cfg.model = Some(model.to_string()); } - let bottom = BottomPane::new(BottomPaneParams { + let mut bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), frame_requester: FrameRequester::test_dummy(), has_input_focus: true, @@ -371,6 +371,7 @@ async fn make_chatwidget_manual( animations_enabled: cfg.animations, skills: None, }); + bottom.set_steer_enabled(true); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); let widget = ChatWidget { @@ -1009,7 +1010,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::Tab, KeyModifiers::NONE)); } assert_eq!(chat.queued_user_messages.len(), 3); @@ -1031,7 +1032,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::Tab, KeyModifiers::NONE)); assert_eq!(chat.queued_user_messages.len(), 1); assert_eq!( diff --git a/codex-rs/tui2/tooltips.txt b/codex-rs/tui2/tooltips.txt index 33e891611..0fdb0ca73 100644 --- a/codex-rs/tui2/tooltips.txt +++ b/codex-rs/tui2/tooltips.txt @@ -6,5 +6,6 @@ You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands and saved prompts. You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts When the composer is empty, press Esc to step back and edit your last message; Enter confirms. +Press Tab to queue a message instead of sending it immediately; Enter always sends immediately. Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume`