Queue slash and shell prompts in the TUI (#18542)

## Why

Users have asked to queue follow-up slash commands while a task is
running, including in #14081, #14588, #14286, and #13779. The previous
TUI behavior validated slash commands immediately, so commands that are
only meaningful once the current turn is idle could not be queued
consistently.

The queue should preserve what the user typed and defer command parsing
until the item is actually dispatched. This also gives `/fast`, `/review
...`, `/rename ...`, `/model`, `/permissions`, and similar slash
workflows the same FIFO behavior as plain queued prompts.

## What Changed

- Added a queued-input action enum so queued items can be dispatched as
plain prompts, slash commands, or user shell commands.
- Changed `Tab` queueing to accept slash-led prompts without validating
them up front, then parse and dispatch them when dequeued.
- Added `!` shell-command queueing for `Tab` while a task is running,
while preserving existing `Enter` behavior for immediate shell
execution.
- Moved queued slash dispatch through shared slash-command parsing so
inline commands, unavailable commands, unknown commands, and local
config commands report at dequeue time.
- Continued queue draining after local-only actions and after slash menu
cancellation or selection when no task is running.
- Preserved slash-popup completion behavior so `/mo<Tab>` completes to
`/model ` instead of queueing the prefix.
- Updated pending-input preview snapshots to show queued follow-up
inputs.

## Verification

I did a bunch of manual validation (and found and fixed a few bugs along
the way).
This commit is contained in:
Eric Traut authored and GitHub committed 2026-04-19 10:52:16 -07:00
1 parent 116317021d
commit 917a85b0d6
24 files changed
+1428 -206

No files matched your search

+201 -79
View File
@@ -34,6 +34,7 @@ use std::collections::BTreeSet;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::ops::Deref;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -218,6 +219,7 @@ use codex_protocol::protocol::WebSearchEndEvent;
use codex_protocol::request_permissions::RequestPermissionsEvent;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::request_user_input::RequestUserInputQuestionOption;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use codex_terminal_detection::Multiplexer;
@@ -317,6 +319,7 @@ use crate::bottom_pane::McpServerElicitationFormRequest;
use crate::bottom_pane::MemoriesSettingsView;
use crate::bottom_pane::MentionBinding;
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
use crate::bottom_pane::QueuedInputAction;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
@@ -883,8 +886,10 @@ pub(crate) struct ChatWidget {
// history has been rendered so resumed/forked prompts keep chronological
// order.
suppress_initial_user_message_submit: bool,
// User messages queued while a turn is in progress
queued_user_messages: VecDeque<UserMessage>,
// User inputs queued while a turn is in progress.
queued_user_messages: VecDeque<QueuedUserMessage>,
// A user turn has been submitted to core, but `TurnStarted` has not arrived yet.
user_turn_pending_start: bool,
// User messages that tried to steer a non-regular turn and must be retried first.
rejected_steers_queue: VecDeque<UserMessage>,
// Steers already submitted to core but not yet committed into history.
@@ -1043,6 +1048,45 @@ pub(crate) struct UserMessage {
mention_bindings: Vec<MentionBinding>,
}
#[derive(Debug, Clone, PartialEq)]
struct QueuedUserMessage {
user_message: UserMessage,
action: QueuedInputAction,
}
impl QueuedUserMessage {
fn new(user_message: UserMessage, action: QueuedInputAction) -> Self {
Self {
user_message,
action,
}
}
fn into_user_message(self) -> UserMessage {
self.user_message
}
}
impl From<UserMessage> for QueuedUserMessage {
fn from(user_message: UserMessage) -> Self {
Self::new(user_message, QueuedInputAction::Plain)
}
}
impl Deref for QueuedUserMessage {
type Target = UserMessage;
fn deref(&self) -> &Self::Target {
&self.user_message
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QueueDrain {
Continue,
Stop,
}
#[derive(Debug, Clone, PartialEq, Default)]
struct ThreadComposerState {
text: String,
@@ -1069,7 +1113,8 @@ pub(crate) struct ThreadInputState {
composer: Option<ThreadComposerState>,
pending_steers: VecDeque<UserMessage>,
rejected_steers_queue: VecDeque<UserMessage>,
queued_user_messages: VecDeque<UserMessage>,
queued_user_messages: VecDeque<QueuedUserMessage>,
user_turn_pending_start: bool,
current_collaboration_mode: CollaborationMode,
active_collaboration_mask: Option<CollaborationModeMask>,
task_running: bool,
@@ -2134,6 +2179,7 @@ impl ChatWidget {
self.refresh_terminal_title();
self.refresh_status_surfaces();
self.request_redraw();
self.maybe_send_next_queued_input();
}
}
@@ -2326,6 +2372,7 @@ impl ChatWidget {
// Raw reasoning uses the same flow as summarized reasoning
fn on_task_started(&mut self) {
self.user_turn_pending_start = false;
self.agent_turn_running = true;
self.turn_sleep_inhibitor
.set_turn_running(/*turn_running*/ true);
@@ -2420,6 +2467,7 @@ impl ChatWidget {
}
// Mark task stopped and request redraw now that all content is in history.
self.pending_status_indicator_restore = false;
self.user_turn_pending_start = false;
self.agent_turn_running = false;
self.turn_sleep_inhibitor
.set_turn_running(/*turn_running*/ false);
@@ -2526,19 +2574,20 @@ impl ChatWidget {
!self.rejected_steers_queue.is_empty() || !self.queued_user_messages.is_empty()
}
fn pop_next_queued_user_message(&mut self) -> Option<UserMessage> {
fn pop_next_queued_user_message(&mut self) -> Option<QueuedUserMessage> {
if self.rejected_steers_queue.is_empty() {
self.queued_user_messages.pop_front()
} else {
Some(merge_user_messages(
Some(QueuedUserMessage::from(merge_user_messages(
self.rejected_steers_queue.drain(..).collect(),
))
)))
}
}
fn pop_latest_queued_user_message(&mut self) -> Option<UserMessage> {
self.queued_user_messages
.pop_back()
.map(QueuedUserMessage::into_user_message)
.or_else(|| self.rejected_steers_queue.pop_back())
}
@@ -2830,6 +2879,7 @@ impl ChatWidget {
// Ensure any spinner is replaced by a red ✗ and flushed into history.
self.finalize_active_cell_as_failed();
// Reset running state and clear streaming buffers.
self.user_turn_pending_start = false;
self.agent_turn_running = false;
self.turn_sleep_inhibitor
.set_turn_running(/*turn_running*/ false);
@@ -3211,7 +3261,11 @@ impl ChatWidget {
.drain(..)
.map(|steer| steer.user_message),
);
to_merge.extend(self.queued_user_messages.drain(..));
to_merge.extend(
self.queued_user_messages
.drain(..)
.map(QueuedUserMessage::into_user_message),
);
if !existing_message.text.is_empty()
|| !existing_message.local_images.is_empty()
|| !existing_message.remote_image_urls.is_empty()
@@ -3258,6 +3312,7 @@ impl ChatWidget {
.collect(),
rejected_steers_queue: self.rejected_steers_queue.clone(),
queued_user_messages: self.queued_user_messages.clone(),
user_turn_pending_start: self.user_turn_pending_start,
current_collaboration_mode: self.current_collaboration_mode.clone(),
active_collaboration_mask: self.active_collaboration_mask.clone(),
task_running: self.bottom_pane.is_task_running(),
@@ -3271,6 +3326,7 @@ 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.user_turn_pending_start = input_state.user_turn_pending_start;
self.update_collaboration_mode_indicator();
self.refresh_model_dependent_surfaces();
if let Some(composer) = input_state.composer {
@@ -3314,6 +3370,7 @@ impl ChatWidget {
self.queued_user_messages = input_state.queued_user_messages;
} else {
self.agent_turn_running = false;
self.user_turn_pending_start = false;
self.pending_steers.clear();
self.rejected_steers_queue.clear();
self.set_remote_image_urls(Vec::new());
@@ -4460,6 +4517,7 @@ impl ChatWidget {
let parsed = self.annotate_skill_reads_in_parsed_cmd(parsed);
let is_unified_exec_interaction =
matches!(source, ExecCommandSource::UnifiedExecInteraction);
let is_user_shell = source == ExecCommandSource::UserShell;
let end_target = match self.active_cell.as_ref() {
Some(cell) => match cell.as_any().downcast_ref::<ExecCell>() {
Some(exec_cell)
@@ -4553,6 +4611,9 @@ impl ChatWidget {
}
// Mark that actual work was done (command executed)
self.had_work_activity = true;
if is_user_shell {
self.maybe_send_next_queued_input();
}
}
pub(crate) fn handle_patch_apply_end_now(
@@ -4928,6 +4989,7 @@ impl ChatWidget {
thread_name: None,
forked_from: None,
queued_user_messages: VecDeque::new(),
user_turn_pending_start: false,
rejected_steers_queue: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
@@ -5116,70 +5178,77 @@ impl ChatWidget {
{
self.cycle_collaboration_mode();
}
_ => match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted {
text,
text_elements,
} => {
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
_ => {
let had_modal_or_popup = !self.bottom_pane.no_modal_or_popup_active();
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: self
} => {
let local_images = self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
if user_message.text.is_empty()
&& user_message.local_images.is_empty()
&& user_message.remote_image_urls.is_empty()
{
return;
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
if user_message.text.is_empty()
&& user_message.local_images.is_empty()
&& user_message.remote_image_urls.is_empty()
{
return;
}
let should_submit_now =
self.is_session_configured() && !self.is_plan_streaming_in_tui();
if should_submit_now {
// Submitted is emitted when user submits.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
let should_submit_now =
self.is_session_configured() && !self.is_plan_streaming_in_tui();
if should_submit_now {
// Submitted is emitted when user submits.
// Reset any reasoning header only when we are actually submitting a turn.
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
} else {
self.queue_user_message(user_message);
}
}
InputResult::Queued {
text,
text_elements,
} => {
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
InputResult::Queued {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: self
action,
} => {
let local_images = self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
self.queue_user_message(user_message);
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
let user_message = UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings: self
.bottom_pane
.take_recent_submission_mention_bindings(),
};
self.queue_user_message_with_options(user_message, action);
}
InputResult::Command(cmd) => {
self.handle_slash_command_dispatch(cmd);
}
InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.handle_slash_command_with_args_dispatch(cmd, args, text_elements);
}
InputResult::None => {}
}
InputResult::Command(cmd) => {
self.handle_slash_command_dispatch(cmd);
if had_modal_or_popup && self.bottom_pane.no_modal_or_popup_active() {
self.maybe_send_next_queued_input();
}
InputResult::CommandWithArgs(cmd, args, text_elements) => {
self.handle_slash_command_with_args_dispatch(cmd, args, text_elements);
}
InputResult::None => {}
},
}
}
}
@@ -5360,18 +5429,54 @@ impl ChatWidget {
}
fn queue_user_message(&mut self, user_message: UserMessage) {
if !self.is_session_configured() || self.bottom_pane.is_task_running() {
self.queued_user_messages.push_back(user_message);
self.queue_user_message_with_options(user_message, QueuedInputAction::Plain);
}
fn queue_user_message_with_options(
&mut self,
user_message: UserMessage,
action: QueuedInputAction,
) {
if !self.is_session_configured() || self.is_user_turn_pending_or_running() {
self.queued_user_messages
.push_back(QueuedUserMessage::new(user_message, action));
self.refresh_pending_input_preview();
} else {
self.submit_user_message(user_message);
}
}
fn submit_shell_command(&mut self, command: &str) -> QueueDrain {
let cmd = command.trim();
if cmd.is_empty() {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(
USER_SHELL_COMMAND_HELP_TITLE.to_string(),
Some(USER_SHELL_COMMAND_HELP_HINT.to_string()),
),
)));
QueueDrain::Continue
} else {
self.submit_op(AppCommand::run_user_shell_command(cmd.to_string()));
QueueDrain::Stop
}
}
fn submit_queued_shell_prompt(&mut self, user_message: UserMessage) -> QueueDrain {
match user_message.text.strip_prefix('!') {
Some(command) => self.submit_shell_command(command),
None => {
self.submit_user_message(user_message);
QueueDrain::Stop
}
}
}
fn submit_user_message(&mut self, user_message: UserMessage) {
if !self.is_session_configured() {
tracing::warn!("cannot submit user message before session is configured; queueing");
self.queued_user_messages.push_front(user_message);
self.queued_user_messages
.push_front(QueuedUserMessage::from(user_message));
self.refresh_pending_input_preview();
return;
}
@@ -5403,17 +5508,7 @@ impl ChatWidget {
// Special-case: "!cmd" executes a local shell command instead of sending to the model.
if let Some(stripped) = text.strip_prefix('!') {
let cmd = stripped.trim();
if cmd.is_empty() {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(
USER_SHELL_COMMAND_HELP_TITLE.to_string(),
Some(USER_SHELL_COMMAND_HELP_HINT.to_string()),
),
)));
return;
}
self.submit_op(AppCommand::run_user_shell_command(cmd.to_string()));
self.submit_shell_command(stripped);
return;
}
@@ -5589,6 +5684,9 @@ impl ChatWidget {
if !self.submit_op(op) {
return;
}
if render_in_history {
self.user_turn_pending_start = true;
}
// Persist the text to cross-session message history. Mentions are
// encoded into placeholder syntax so recall can reconstruct the
@@ -7103,16 +7201,40 @@ impl ChatWidget {
if self.suppress_queue_autosend {
return;
}
if self.bottom_pane.is_task_running() {
if self.is_user_turn_pending_or_running() {
return;
}
if let Some(user_message) = self.pop_next_queued_user_message() {
self.submit_user_message(user_message);
while !self.is_user_turn_pending_or_running() {
let Some(queued_message) = self.pop_next_queued_user_message() else {
break;
};
match queued_message.action {
QueuedInputAction::Plain => {
self.submit_user_message(queued_message.into_user_message());
break;
}
QueuedInputAction::ParseSlash => {
let drain = self.submit_queued_slash_prompt(queued_message.into_user_message());
if drain == QueueDrain::Stop {
break;
}
}
QueuedInputAction::RunShell => {
let drain = self.submit_queued_shell_prompt(queued_message.into_user_message());
if drain == QueueDrain::Stop {
break;
}
}
}
}
// Update the list to reflect the remaining queued messages (if any).
self.refresh_pending_input_preview();
}
pub(super) fn is_user_turn_pending_or_running(&self) -> bool {
self.user_turn_pending_start || self.bottom_pane.is_task_running()
}
/// Rebuild and update the bottom-pane pending-input preview.
fn refresh_pending_input_preview(&mut self) {
let queued_messages: Vec<String> = self