From a3e4bd3bc0086d424e35b349086e5f6d7459a7d9 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 9 Feb 2026 16:39:09 -0800 Subject: [PATCH] fix(tui): tab submits when no task running in steer mode (#10035) When steer mode is enabled, Tab used to only queue while a task was running and otherwise did nothing. Treat Tab as an immediate submit when no task is running so input isn't dropped when the inflight turn ends mid-typing. Adds a regression test and updates docs/tooltips. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 82 ++++++++++++++++++- codex-rs/tui/tooltips.txt | 2 +- docs/tui-chat-composer.md | 4 + 3 files changed, 85 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index c14cbc082..8bf46bdc6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -28,6 +28,10 @@ //! //! # Submission and Prompt Expansion //! +//! When steer is enabled, `Enter` submits immediately. `Tab` requests queuing while a task is +//! running; if no task is running, `Tab` submits just like Enter so input is never dropped. +//! `Tab` does not submit when entering a `!` shell command. +//! //! On submit/queue paths, the composer: //! //! - Expands pending paste placeholders so element ranges align with the final text. @@ -444,7 +448,8 @@ impl ChatComposer { /// Enables or disables "Steer" behavior for submission keys. /// /// When steer is enabled, `Enter` produces [`InputResult::Submitted`] (send immediately) and - /// `Tab` produces [`InputResult::Queued`] (eligible to queue if a task is running). + /// `Tab` produces [`InputResult::Queued`] when a task is running; otherwise it submits + /// immediately. `Tab` does not submit when the input is a `!` shell command. /// When steer is disabled, `Enter` produces [`InputResult::Queued`], preserving the default /// "queue while a task is running" behavior. pub fn set_steer_enabled(&mut self, enabled: bool) { @@ -2383,7 +2388,17 @@ impl ChatComposer { modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, .. - } if self.is_task_running => self.handle_submission(true), + } if self.steer_enabled && !self.is_bang_shell_command() => { + self.handle_submission(self.is_task_running) + } + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + .. + } if self.is_task_running && !self.is_bang_shell_command() => { + self.handle_submission(true) + } KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, @@ -2396,6 +2411,10 @@ impl ChatComposer { } } + fn is_bang_shell_command(&self) -> bool { + self.textarea.text().trim_start().starts_with('!') + } + /// Applies any due `PasteBurst` flush at time `now`. /// /// Converts [`PasteBurst::flush_if_due`] results into concrete textarea mutations. @@ -5427,6 +5446,65 @@ mod tests { assert!(elements.is_empty()); } + #[test] + fn tab_submits_when_no_task_running_in_steer_mode() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + + type_chars_humanlike(&mut composer, &['h', 'i']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert!(matches!( + result, + InputResult::Submitted { ref text, .. } if text == "hi" + )); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn tab_does_not_submit_for_bang_shell_command_in_steer_mode() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + composer.set_steer_enabled(true); + composer.set_task_running(false); + + type_chars_humanlike(&mut composer, &['!', 'l', 's']); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + + assert!(matches!(result, InputResult::None)); + assert!( + composer.textarea.text().starts_with("!ls"), + "expected Tab not to submit or clear a `!` command" + ); + } + #[test] fn slash_mention_dispatches_command_and_inserts_at() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 68664a6f4..22f6b3754 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -18,6 +18,6 @@ Visit the Codex community forum: https://community.openai.com/c/codex/37 You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands. 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. +Press Tab to queue a message when a task is running; otherwise it sends immediately (except `!`). 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/docs/tui-chat-composer.md b/docs/tui-chat-composer.md index 89de775e4..1e84a1b13 100644 --- a/docs/tui-chat-composer.md +++ b/docs/tui-chat-composer.md @@ -96,6 +96,10 @@ popup so gating stays in sync. There are multiple submission paths, but they share the same core rules: +When steer mode is enabled, `Tab` requests queuing if a task is already running; otherwise it +submits immediately. `Enter` always submits immediately in this mode. `Tab` does not submit when +the input starts with `!` (shell command). + ### Normal submit/queue path `handle_submission` calls `prepare_submission_text` for both submit and queue. That method: