From bda2d87fc70c361ead752c7a858e8ee6d2fdf0cf Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 16 Apr 2026 22:35:34 -0700 Subject: [PATCH] tui: queue input during standalone shell commands Fixes #17954 --- codex-rs/tui/src/chatwidget.rs | 89 +++++++++++++++---- .../chatwidget/tests/composer_submission.rs | 1 + .../tui/src/chatwidget/tests/exec_flow.rs | 66 ++++++++++++++ codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + .../tui/src/chatwidget/tests/review_mode.rs | 1 + 5 files changed, 141 insertions(+), 18 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2f5258bf2f..4f41925dff 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -887,6 +887,13 @@ pub(crate) struct ChatWidget { // The bottom pane shows these above queued drafts until core records the // corresponding user message item. pending_steers: VecDeque, + // Set after submitting a standalone `!` command while no model turn is + // running. The following TurnStarted is the user shell turn. + pending_standalone_user_shell_command: bool, + // Active turn id for a standalone `!` command. While this is set, Enter + // should queue follow-up input for the next turn instead of steering the + // shell command turn. + standalone_user_shell_turn_id: Option, // When set, the next interrupt should resubmit all pending steers as one // fresh user turn instead of restoring them into the composer. submit_pending_steers_after_interrupt: bool, @@ -1069,6 +1076,7 @@ pub(crate) struct ThreadInputState { active_collaboration_mask: Option, task_running: bool, agent_turn_running: bool, + standalone_user_shell_turn_id: Option, } impl From for UserMessage { @@ -2350,6 +2358,28 @@ impl ChatWidget { self.request_redraw(); } + fn note_turn_started(&mut self, turn_id: &str) { + if self.pending_standalone_user_shell_command { + self.pending_standalone_user_shell_command = false; + self.standalone_user_shell_turn_id = Some(turn_id.to_string()); + } + } + + fn clear_standalone_user_shell_turn(&mut self, turn_id: Option<&str>) { + let should_clear = match (turn_id, self.standalone_user_shell_turn_id.as_deref()) { + (Some(completed), Some(active)) => completed == active, + (None, Some(_)) => true, + _ => false, + }; + if should_clear { + self.standalone_user_shell_turn_id = None; + } + } + + fn is_standalone_user_shell_turn_running(&self) -> bool { + self.standalone_user_shell_turn_id.is_some() && self.bottom_pane.is_task_running() + } + fn on_task_complete(&mut self, last_agent_message: Option, from_replay: bool) { self.submit_pending_steers_after_interrupt = false; // Use `last_agent_message` from the turn-complete notification as the copy @@ -3259,6 +3289,7 @@ impl ChatWidget { active_collaboration_mask: self.active_collaboration_mask.clone(), task_running: self.bottom_pane.is_task_running(), agent_turn_running: self.agent_turn_running, + standalone_user_shell_turn_id: self.standalone_user_shell_turn_id.clone(), }) } @@ -3268,6 +3299,8 @@ impl ChatWidget { self.current_collaboration_mode = input_state.current_collaboration_mode; self.active_collaboration_mask = input_state.active_collaboration_mask; self.agent_turn_running = input_state.agent_turn_running; + self.pending_standalone_user_shell_command = false; + self.standalone_user_shell_turn_id = input_state.standalone_user_shell_turn_id; self.update_collaboration_mode_indicator(); self.refresh_model_dependent_surfaces(); if let Some(composer) = input_state.composer { @@ -3311,6 +3344,8 @@ impl ChatWidget { self.queued_user_messages = input_state.queued_user_messages; } else { self.agent_turn_running = false; + self.pending_standalone_user_shell_command = false; + self.standalone_user_shell_turn_id = None; self.pending_steers.clear(); self.rejected_steers_queue.clear(); self.set_remote_image_urls(Vec::new()); @@ -4924,6 +4959,8 @@ impl ChatWidget { queued_user_messages: VecDeque::new(), rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), + pending_standalone_user_shell_command: false, + standalone_user_shell_turn_id: None, submit_pending_steers_after_interrupt: false, queued_message_edit_binding, show_welcome_banner: is_first_run, @@ -5134,8 +5171,9 @@ impl ChatWidget { { return; } - let should_submit_now = - self.is_session_configured() && !self.is_plan_streaming_in_tui(); + let should_submit_now = self.is_session_configured() + && !self.is_plan_streaming_in_tui() + && !self.is_standalone_user_shell_turn_running(); if should_submit_now { // Submitted is emitted when user submits. // Reset any reasoning header only when we are actually submitting a turn. @@ -5409,7 +5447,12 @@ impl ChatWidget { ))); return; } - self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); + let starts_standalone_user_shell_turn = !self.bottom_pane.is_task_running(); + if self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())) + && starts_standalone_user_shell_turn + { + self.pending_standalone_user_shell_command = true; + } return; } @@ -6124,7 +6167,9 @@ impl ChatWidget { } } ServerNotification::TurnStarted(notification) => { - self.last_turn_id = Some(notification.turn.id); + let turn_id = notification.turn.id; + self.note_turn_started(&turn_id); + self.last_turn_id = Some(turn_id); self.last_non_retry_error = None; if !matches!(replay_kind, Some(ReplayKind::ResumeInitialMessages)) { self.on_task_started(); @@ -6382,6 +6427,7 @@ impl ChatWidget { notification: TurnCompletedNotification, replay_kind: Option, ) { + self.clear_standalone_user_shell_turn(Some(¬ification.turn.id)); match notification.turn.status { TurnStatus::Completed => { self.last_non_retry_error = None; @@ -6695,6 +6741,7 @@ impl ChatWidget { EventMsg::TurnStarted(event) => { let turn_id = event.turn_id; let model_context_window = event.model_context_window; + self.note_turn_started(&turn_id); self.last_turn_id = Some(turn_id); if !is_resume_initial_replay { self.apply_turn_started_context_window(model_context_window); @@ -6702,8 +6749,11 @@ impl ChatWidget { } } EventMsg::TurnComplete(TurnCompleteEvent { - last_agent_message, .. + turn_id, + last_agent_message, + .. }) => { + self.clear_standalone_user_shell_turn(Some(&turn_id)); self.on_task_complete(last_agent_message, from_replay); } EventMsg::TokenCount(ev) => { @@ -6739,20 +6789,23 @@ impl ChatWidget { } EventMsg::McpStartupUpdate(ev) => self.on_mcp_startup_update(ev), EventMsg::McpStartupComplete(ev) => self.on_mcp_startup_complete(ev), - EventMsg::TurnAborted(ev) => match ev.reason { - TurnAbortReason::Interrupted => { - self.on_interrupted_turn(ev.reason); + EventMsg::TurnAborted(ev) => { + self.clear_standalone_user_shell_turn(ev.turn_id.as_deref()); + match ev.reason { + TurnAbortReason::Interrupted => { + self.on_interrupted_turn(ev.reason); + } + TurnAbortReason::Replaced => { + self.submit_pending_steers_after_interrupt = false; + self.pending_steers.clear(); + self.refresh_pending_input_preview(); + self.on_error("Turn aborted: replaced by a new task".to_owned()) + } + TurnAbortReason::ReviewEnded => { + self.on_interrupted_turn(ev.reason); + } } - TurnAbortReason::Replaced => { - self.submit_pending_steers_after_interrupt = false; - self.pending_steers.clear(); - self.refresh_pending_input_preview(); - self.on_error("Turn aborted: replaced by a new task".to_owned()) - } - TurnAbortReason::ReviewEnded => { - self.on_interrupted_turn(ev.reason); - } - }, + } EventMsg::PlanUpdate(update) => self.on_plan_update(update), EventMsg::ExecApprovalRequest(ev) => { // For replayed events, synthesize an empty id (these should not occur). diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 8cafb54dc2..d61cae2497 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -801,6 +801,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: true, agent_turn_running: true, + standalone_user_shell_turn_id: None, })); assert!(chat.agent_turn_running); diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 53f16a39d1..7196664eda 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1027,6 +1027,72 @@ async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() { assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +async fn enter_during_standalone_user_shell_command_queues_follow_up() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane + .set_composer_text("!sleep 10".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match op_rx.try_recv() { + Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "sleep 10"), + other => panic!("expected RunUserShellCommand op, got {other:?}"), + } + + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "shell-turn".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, + }, + }), + /*replay_kind*/ None, + ); + + chat.bottom_pane + .set_composer_text("hi".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(chat.queued_user_message_texts(), vec!["hi"]); + assert!(chat.pending_steers.is_empty()); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + + chat.handle_server_notification( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "shell-turn".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::Completed, + error: None, + started_at: None, + completed_at: Some(0), + duration_ms: None, + }, + }), + /*replay_kind*/ 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 follow-up submit, got {other:?}"), + } +} + #[tokio::test] async fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index c0f881e2b8..01cd43fa14 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -254,6 +254,8 @@ pub(super) async fn make_chatwidget_manual( queued_user_messages: VecDeque::new(), rejected_steers_queue: VecDeque::new(), pending_steers: VecDeque::new(), + pending_standalone_user_shell_command: false, + standalone_user_shell_turn_id: None, submit_pending_steers_after_interrupt: false, queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up), suppress_session_configured_redraw: false, diff --git a/codex-rs/tui/src/chatwidget/tests/review_mode.rs b/codex-rs/tui/src/chatwidget/tests/review_mode.rs index c42ec4fbac..0a39b5fac2 100644 --- a/codex-rs/tui/src/chatwidget/tests/review_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/review_mode.rs @@ -365,6 +365,7 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_ active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: false, agent_turn_running: false, + standalone_user_shell_turn_id: None, })); assert_eq!(