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
This commit is contained in:
Eric Traut
2026-04-21 10:13:13 -07:00
committed by GitHub
Unverified
parent 41652665f5
commit b7fec54354
2 changed files with 67 additions and 0 deletions
+15
View File
@@ -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<String> = self
@@ -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