tui: resolve interactive slash commands before queueing

Mark popup-opening slash commands as requiring interaction, open them immediately instead of queueing bare drafts while a task is running, and make queued replay restore any legacy bare interactive command draft instead of opening UI mid-drain.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-15 23:32:50 -07:00
co-authored by Codex
parent 8267155494
commit 173d0ef4e3
3 changed files with 66 additions and 4 deletions
+12 -4
View File
@@ -4321,12 +4321,13 @@ impl ChatWidget {
///
/// Live callers usually ignore the return value, but queued replay uses it to decide whether
/// draining can continue after this command. `Continue` means the command only changed local
/// UI/config state, while `Stop` means it opened UI, submitted work, or otherwise hit a
/// boundary that should stop queued draining.
/// UI/config state. `Stop` means it submitted or queued work, changed session/navigation
/// state, or otherwise hit a boundary where queued draining must stop. Commands that require
/// interactive UI are resolved before queueing and should not open that UI during replay.
fn dispatch_command(&mut self, cmd: SlashCommand) -> QueueReplayControl {
if self.bottom_pane.is_task_running()
&& !cmd.available_during_task()
&& !matches!(cmd, SlashCommand::Model | SlashCommand::Review)
&& !cmd.requires_interaction()
{
self.queue_user_message(format!("/{}", cmd.command()).into());
// This busy-path queueing only happens for live command dispatch. Queued replay
@@ -4707,7 +4708,7 @@ impl ChatWidget {
let trimmed = args.trim();
let should_queue = self.bottom_pane.is_task_running() && !cmd.available_during_task();
if trimmed.is_empty() {
if should_queue && !matches!(cmd, SlashCommand::Model | SlashCommand::Review) {
if should_queue && !cmd.requires_interaction() {
self.queue_current_inline_bare_slash_command(cmd);
} else {
self.dispatch_command(cmd);
@@ -5081,6 +5082,13 @@ impl ChatWidget {
return QueueReplayControl::Stop;
};
if rest.trim().is_empty() {
if cmd.requires_interaction() {
self.add_error_message(format!(
"Failed to replay queued slash command requiring interaction: {preview}"
));
self.restore_user_message_to_composer(draft);
return QueueReplayControl::Stop;
}
let replay_control = self.dispatch_command(cmd);
if replay_control == QueueReplayControl::Continue
&& self.bottom_pane.no_modal_or_popup_active()
+31
View File
@@ -8232,6 +8232,18 @@ async fn model_slash_command_while_task_running_opens_popup_snapshot() {
);
}
#[tokio::test]
async fn theme_slash_command_while_task_running_opens_popup_instead_of_queueing() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.dispatch_command(SlashCommand::Theme);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.has_active_view(), "expected /theme popup to open");
assert!(drain_insert_history(&mut rx).is_empty());
}
#[tokio::test]
async fn model_selection_queues_selected_action_while_task_running() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
@@ -11570,6 +11582,25 @@ async fn queued_review_selection_replays_after_turn_complete() {
}
}
#[tokio::test]
async fn queued_bare_theme_command_restores_to_composer_instead_of_opening_popup() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.queued_user_messages
.push_back(UserMessage::from("/theme".to_string()));
chat.on_task_complete(None, false);
assert_eq!(chat.bottom_pane.composer_text(), "/theme");
assert!(!chat.has_active_view(), "expected no popup during replay");
assert!(chat.queued_user_messages.is_empty());
assert_no_submit_op(&mut op_rx);
assert!(
!drain_insert_history(&mut rx).is_empty(),
"expected replay failure to be surfaced to the user"
);
}
#[tokio::test]
async fn queued_custom_review_selection_preserves_branch_like_instructions_after_turn_complete() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
+23
View File
@@ -133,6 +133,29 @@ impl SlashCommand {
)
}
/// Whether bare dispatch opens interactive UI that should be resolved before queueing.
pub fn requires_interaction(self) -> bool {
matches!(
self,
SlashCommand::Feedback
| SlashCommand::Resume
| SlashCommand::Review
| SlashCommand::Rename
| SlashCommand::Model
| SlashCommand::Settings
| SlashCommand::Personality
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::MultiAgents
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::Experimental
| SlashCommand::Skills
| SlashCommand::Statusline
| SlashCommand::Theme
)
}
/// Whether this command can be run while a task is in progress.
pub fn available_during_task(self) -> bool {
match self {