From b7fec54354096e3c1884de67b8f2ffbdf836f63e Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 21 Apr 2026 10:13:13 -0700 Subject: [PATCH] Queue follow-up input during user shell commands (#18820) Fixes #17954. ## Why When a manual shell command like `!sleep 10` is running, submitting plain text such as `hi` currently sends that text as a steer for the active shell turn. User shell turns are not steerable like model turns, so the TUI can remain stuck in `Working` after the shell command finishes. ## What Changed - Detect when the only active work is one or more `ExecCommandSource::UserShell` commands. - Queue plain submitted input in that state so it drains after the shell command and shell turn complete. - Preserve `!cmd` submissions during running work so explicit shell commands keep their existing behavior. - Add regression coverage for the `!sleep 10` plus `hi` flow in `chatwidget::tests::exec_flow::user_message_during_user_shell_command_is_queued_not_steered`. ## Verification - Manually confirmed hang before the fix and no hang after the fix --- codex-rs/tui/src/chatwidget.rs | 15 ++++++ .../tui/src/chatwidget/tests/exec_flow.rs | 52 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 860846f28..d226fa3c8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5411,6 +5411,12 @@ impl ChatWidget { let should_submit_now = self.is_session_configured() && !self.is_plan_streaming_in_tui(); if should_submit_now { + if self.only_user_shell_commands_running() + && !user_message.text.starts_with('!') + { + self.queue_user_message(user_message); + return; + } // Submitted is emitted when user submits. // Reset any reasoning header only when we are actually submitting a turn. self.reasoning_buffer.clear(); @@ -7476,6 +7482,15 @@ impl ChatWidget { self.user_turn_pending_start || self.bottom_pane.is_task_running() } + fn only_user_shell_commands_running(&self) -> bool { + self.agent_turn_running + && !self.running_commands.is_empty() + && self + .running_commands + .values() + .all(|command| command.source == ExecCommandSource::UserShell) + } + /// Rebuild and update the bottom-pane pending-input preview. fn refresh_pending_input_preview(&mut self) { let queued_messages: Vec = self diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 9f181d3d2..04a5fa44b 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1036,6 +1036,58 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +async fn user_message_during_user_shell_command_is_queued_not_steered() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.handle_codex_event(Event { + id: "turn-start".into(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: ModeKind::Default, + }), + }); + let begin = begin_exec_with_source( + &mut chat, + "user-shell-sleep", + "sleep 10", + ExecCommandSource::UserShell, + ); + + assert!(chat.only_user_shell_commands_running()); + chat.bottom_pane + .set_composer_text("hi".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert_eq!(chat.queued_user_message_texts(), vec!["hi".to_string()]); + + end_exec(&mut chat, begin, "", "", /*exit_code*/ 0); + chat.handle_codex_event(Event { + id: "turn-complete".into(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: "turn-1".to_string(), + last_agent_message: Some("done".to_string()), + completed_at: None, + duration_ms: None, + }), + }); + + match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => assert_eq!( + items, + vec![UserInput::Text { + text: "hi".to_string(), + text_elements: Vec::new(), + }] + ), + other => panic!("expected queued user message after shell completion, got {other:?}"), + } + assert!(chat.queued_user_messages.is_empty()); +} + #[tokio::test] async fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task