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
parent 41652665f5
commit b7fec54354
2 changed files with 67 additions and 0 deletions
@@ -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