tui: restore interactive slash queue behavior

Keep bare /model and /review interactive while preserving serialized queue replay, restore queued slash drafts into the composer on interrupt, and align queued slash parsing with the same feature-gated lookup used by the composer.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-15 23:32:49 -07:00
co-authored by Codex
parent 35e8aa9ce0
commit fa50564579
5 changed files with 315 additions and 95 deletions
+38 -5
View File
@@ -15,6 +15,7 @@
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::pending_input_preview::PendingInputPreview;
@@ -31,6 +32,7 @@ use codex_core::features::Features;
use codex_core::plugins::PluginCapabilitySummary;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
@@ -148,6 +150,8 @@ pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
pub(crate) use prompt_args::parse_slash_name;
pub(crate) use slash_commands::BuiltinCommandFlags;
pub(crate) use slash_commands::find_builtin_command;
/// Pane displayed in the lower half of the chat UI.
///
@@ -408,16 +412,19 @@ impl BottomPane {
self.request_redraw();
InputResult::None
} else {
// If a task is running and a status line is visible, allow Esc to
// send an interrupt when no popup is active.
// If a task is running, allow Esc to send an interrupt when no popup is active.
// Final-message streaming can temporarily hide the status widget, but that should not
// disable interrupt.
if key_event.code == KeyCode::Esc
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& self.is_task_running
&& !self.composer.popup_active()
&& let Some(status) = &self.status
{
// Send Op::Interrupt
status.interrupt();
if let Some(status) = &self.status {
status.interrupt();
} else {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
}
self.request_redraw();
return InputResult::None;
}
@@ -1828,6 +1835,32 @@ mod tests {
assert_eq!(pane.composer_text(), "still editing");
}
#[test]
fn esc_interrupts_running_task_when_status_indicator_hidden() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.hide_status_indicator();
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
"expected Esc to send Op::Interrupt even when the status indicator is hidden"
);
}
#[test]
fn esc_routes_to_handle_key_event_when_requested() {
#[derive(Default)]
+92 -39
View File
@@ -42,9 +42,11 @@ use self::realtime::PendingSteerCompareKey;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::BuiltinCommandFlags;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLinePreviewData;
use crate::bottom_pane::StatusLineSetupView;
use crate::bottom_pane::find_builtin_command;
use crate::status::RateLimitWindowDisplay;
use crate::status::format_directory_display;
use crate::status::format_tokens_compact;
@@ -2237,9 +2239,9 @@ impl ChatWidget {
/// Each pending message numbers attachments from `[Image #1]` relative to its own remote
/// images. When we concatenate multiple messages after interrupt, we must renumber local-image
/// placeholders in a stable order and rebase text element byte ranges so the restored composer
/// state stays aligned with the merged attachment list. Queued slash-command actions stay in
/// the queue so they can still replay after the interrupt instead of being collapsed into plain
/// draft text.
/// state stays aligned with the merged attachment list. Slash commands are fully serializable
/// again, so queued slash drafts are restored alongside ordinary queued follow-ups instead of
/// being replayed separately after the interrupt.
fn drain_restorable_messages_for_restore(&mut self) -> Option<UserMessage> {
let existing_message = UserMessage {
text: self.bottom_pane.composer_text(),
@@ -2252,12 +2254,9 @@ impl ChatWidget {
let has_existing_message = !existing_message.text.is_empty()
|| !existing_message.local_images.is_empty()
|| !existing_message.remote_image_urls.is_empty();
let has_pending_user_messages = !self.pending_steers.is_empty()
|| self
.queued_user_messages
.iter()
.any(|queued| !Self::is_builtin_slash_draft(queued));
if !has_existing_message && !has_pending_user_messages {
let has_pending_user_messages =
!self.pending_steers.is_empty() || !self.queued_user_messages.is_empty();
if !has_pending_user_messages {
return None;
}
@@ -2266,15 +2265,7 @@ impl ChatWidget {
.drain(..)
.map(|steer| steer.user_message)
.collect();
let mut retained_queue = VecDeque::new();
for queued_message in self.queued_user_messages.drain(..) {
if Self::is_builtin_slash_draft(&queued_message) {
retained_queue.push_back(queued_message);
} else {
to_merge.push(queued_message);
}
}
self.queued_user_messages = retained_queue;
to_merge.extend(self.queued_user_messages.drain(..));
if has_existing_message {
to_merge.push(existing_message);
}
@@ -4208,6 +4199,9 @@ impl ChatWidget {
else {
return;
};
if self.reject_unavailable_builtin_slash_command(&user_message) {
return;
}
let should_submit_now =
self.is_session_configured() && !self.is_plan_streaming_in_tui();
if should_submit_now {
@@ -4243,6 +4237,9 @@ impl ChatWidget {
else {
return;
};
if self.reject_unavailable_builtin_slash_command(&user_message) {
return;
}
self.queue_user_message(user_message);
}
InputResult::Command(cmd) => {
@@ -4321,7 +4318,10 @@ impl ChatWidget {
}
fn dispatch_command(&mut self, cmd: SlashCommand) -> QueueReplayControl {
if self.bottom_pane.is_task_running() && !cmd.available_during_task() {
if self.bottom_pane.is_task_running()
&& !cmd.available_during_task()
&& !matches!(cmd, SlashCommand::Model | SlashCommand::Review)
{
self.queue_user_message(format!("/{}", cmd.command()).into());
return QueueReplayControl::Stop;
}
@@ -4698,7 +4698,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 {
if should_queue && !matches!(cmd, SlashCommand::Model | SlashCommand::Review) {
self.queue_current_inline_bare_slash_command(cmd);
} else {
self.dispatch_command(cmd);
@@ -4982,7 +4982,10 @@ impl ChatWidget {
match &review_request.target {
ReviewTarget::UncommittedChanges => "/review uncommitted".into(),
ReviewTarget::BaseBranch { branch } => format!("/review branch {branch}").into(),
ReviewTarget::Commit { sha, .. } => format!("/review commit {sha}").into(),
ReviewTarget::Commit { sha, title } => match title.as_deref().map(str::trim) {
Some("") | None => format!("/review commit {sha}").into(),
Some(title) => format!("/review commit {sha} {title}").into(),
},
ReviewTarget::Custom { instructions } => format!("/review {instructions}").into(),
}
}
@@ -5006,19 +5009,64 @@ impl ChatWidget {
}
}
fn parse_builtin_slash_command(text: &str) -> Option<(SlashCommand, &str, usize)> {
fn parse_builtin_slash_command<'a>(
&self,
text: &'a str,
) -> Option<(SlashCommand, &'a str, usize)> {
let (name, rest, rest_offset) = parse_slash_name(text)?;
let cmd = SlashCommand::from_str(name).ok()?;
let cmd = find_builtin_command(
name,
BuiltinCommandFlags {
collaboration_modes_enabled: self.collaboration_modes_enabled(),
connectors_enabled: self.connectors_enabled(),
fast_command_enabled: self.fast_mode_enabled(),
personality_command_enabled: self.config.features.enabled(Feature::Personality),
realtime_conversation_enabled: self.realtime_conversation_enabled(),
audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(),
allow_elevate_sandbox: {
#[cfg(target_os = "windows")]
{
codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& matches!(
WindowsSandboxLevel::from_config(&self.config),
WindowsSandboxLevel::RestrictedToken
)
}
#[cfg(not(target_os = "windows"))]
{
false
}
},
},
)?;
Some((cmd, rest, rest_offset))
}
fn is_builtin_slash_draft(draft: &UserMessage) -> bool {
Self::parse_builtin_slash_command(&draft.text).is_some()
fn is_builtin_slash_draft(&self, draft: &UserMessage) -> bool {
self.parse_builtin_slash_command(&draft.text).is_some()
}
fn reject_unavailable_builtin_slash_command(&mut self, user_message: &UserMessage) -> bool {
let Some((name, _, _)) = parse_slash_name(&user_message.text) else {
return false;
};
if name.contains('/')
|| self
.parse_builtin_slash_command(&user_message.text)
.is_some()
|| SlashCommand::from_str(name).is_err()
{
return false;
}
self.add_error_message(format!("/{name} is not available in this session."));
self.restore_user_message_to_composer(user_message.clone());
true
}
fn execute_serialized_slash_command(&mut self, draft: UserMessage) -> QueueReplayControl {
let preview = draft.text.clone();
let Some((cmd, rest, rest_offset)) = Self::parse_builtin_slash_command(&preview) else {
let Some((cmd, rest, rest_offset)) = self.parse_builtin_slash_command(&preview) else {
self.add_error_message(format!("Failed to replay queued slash command: {preview}"));
self.restore_user_message_to_composer(draft);
return QueueReplayControl::Stop;
@@ -5079,6 +5127,8 @@ impl ChatWidget {
}
fn parse_review_request(args: &str) -> Result<ReviewRequest, String> {
const REVIEW_USAGE: &str =
"Usage: /review [uncommitted|branch <name>|commit <sha> [title]|<instructions>]";
let trimmed = args.trim();
let lowered = trimmed.to_ascii_lowercase();
let target = if matches!(
@@ -5093,20 +5143,23 @@ impl ChatWidget {
branch: value.to_string(),
},
"branch" => {
return Err(
"Usage: /review [uncommitted|branch <name>|commit <sha>|<instructions>]"
.to_string(),
);
return Err(REVIEW_USAGE.to_string());
}
"commit" if !value.is_empty() => ReviewTarget::Commit {
sha: value.to_string(),
title: None,
},
"commit" => {
return Err(
"Usage: /review [uncommitted|branch <name>|commit <sha>|<instructions>]"
.to_string(),
"commit" if !value.is_empty() => {
let (sha, title) = value.split_once(char::is_whitespace).map_or(
(value, None),
|(sha, title)| {
let title = title.trim();
(sha, (!title.is_empty()).then_some(title.to_string()))
},
);
ReviewTarget::Commit {
sha: sha.to_string(),
title,
}
}
"commit" => {
return Err(REVIEW_USAGE.to_string());
}
_ => ReviewTarget::Custom {
instructions: trimmed.to_string(),
@@ -6094,7 +6147,7 @@ impl ChatWidget {
let Some(queued_message) = self.queued_user_messages.pop_front() else {
break;
};
let replay_control = if Self::is_builtin_slash_draft(&queued_message) {
let replay_control = if self.is_builtin_slash_draft(&queued_message) {
self.execute_serialized_slash_command(queued_message)
} else {
self.submit_user_message(queued_message);
@@ -0,0 +1,22 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. gpt-5.3-codex (default) Latest frontier agentic coding model.
2. gpt-5.4 Latest frontier agentic coding model.
3. gpt-5.2-codex Frontier agentic coding model.
4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast
reasoning.
5. gpt-5.2 Latest frontier model with improvements across
knowledge, reasoning and coding
6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
capable.
Press enter to select reasoning effort, or esc to dismiss.
@@ -1,22 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
• Working (0s • esc to interrupt)
• Queued follow-up messages
↳ /model
⌥ + ↑ edit last queued message
› Ask Codex to do anything
? for shortcuts 100% context left
+163 -29
View File
@@ -8205,18 +8205,15 @@ async fn user_shell_command_renders_output_not_exploring() {
}
#[tokio::test]
async fn model_slash_command_while_task_running_queues_snapshot() {
async fn model_slash_command_while_task_running_opens_popup_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.dispatch_command(SlashCommand::Model);
assert_eq!(chat.queued_user_message_texts(), vec!["/model".to_string()]);
assert!(
!chat.has_active_view(),
"expected /model to queue instead of opening a popup"
);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.has_active_view(), "expected /model popup to open");
assert!(drain_insert_history(&mut rx).is_empty());
let width: u16 = 80;
@@ -8230,7 +8227,7 @@ async fn model_slash_command_while_task_running_queues_snapshot() {
})
.unwrap();
assert_snapshot!(
"model_slash_command_while_task_running_queues",
"model_slash_command_while_task_running_popup",
term.backend().vt100().screen().contents()
);
}
@@ -8252,6 +8249,37 @@ async fn model_selection_queues_selected_action_while_task_running() {
);
}
#[tokio::test]
async fn interrupt_restores_queued_model_selection_into_composer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
chat.on_task_started();
chat.handle_serialized_slash_command(ChatWidget::model_selection_draft(
"gpt-5.1-codex-max",
Some(ReasoningEffortConfig::High),
ModelSelectionScope::Global,
));
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnAborted(codex_protocol::protocol::TurnAbortedEvent {
turn_id: Some("turn-1".to_string()),
reason: TurnAbortReason::Interrupted,
}),
});
assert_eq!(
chat.bottom_pane.composer_text(),
"/model gpt-5.1-codex-max high"
);
assert!(chat.queued_user_messages.is_empty());
assert!(
op_rx.try_recv().is_err(),
"unexpected outbound op after interrupt restoring queued /model selection"
);
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn model_slash_command_with_args_queues_while_task_running_and_replays_after_turn_complete() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
@@ -8362,13 +8390,19 @@ async fn esc_interrupts_running_task_with_empty_composer() {
}
#[tokio::test]
async fn esc_interrupts_running_task_with_nonempty_composer_and_restores_draft() {
async fn esc_interrupts_running_task_with_nonempty_composer_without_restoring_draft() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.bottom_pane
.set_composer_text("still editing".to_string(), Vec::new(), Vec::new());
assert!(
chat.drain_restorable_messages_for_restore().is_none(),
"existing composer text alone should not trigger draft restoration"
);
assert_eq!(chat.bottom_pane.composer_text(), "still editing");
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
@@ -8399,6 +8433,25 @@ async fn esc_with_model_popup_active_dismisses_popup_without_interrupting() {
assert!(!chat.has_active_view(), "expected /model popup to close");
}
#[tokio::test]
async fn esc_interrupts_running_task_while_final_message_streaming() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.on_commit_tick();
let _ = drain_insert_history(&mut rx);
assert!(
!chat.bottom_pane.status_indicator_visible(),
"expected final-message streaming to hide the status indicator"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
next_interrupt_op(&mut op_rx);
}
#[tokio::test]
async fn esc_with_popup_active_does_not_interrupt_pending_steers() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.1-codex")).await;
@@ -8458,6 +8511,50 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn disabled_fast_slash_command_with_args_restores_draft_instead_of_queueing() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::FastMode, false);
chat.on_task_started();
chat.bottom_pane
.set_composer_text("/fast on".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "/fast on");
assert!(chat.queued_user_messages.is_empty());
assert_no_submit_op(&mut op_rx);
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn queued_disabled_fast_slash_draft_replays_as_user_text() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.thread_id = Some(ThreadId::new());
chat.set_feature_enabled(Feature::FastMode, false);
chat.on_task_started();
chat.handle_serialized_slash_command(UserMessage::from("/fast on".to_string()));
assert_eq!(
chat.queued_user_message_texts(),
vec!["/fast on".to_string()]
);
chat.on_task_complete(None, false);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "/fast on".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued disabled slash draft Op::UserTurn, got {other:?}"),
}
}
#[tokio::test]
async fn user_turn_carries_service_tier_after_fast_toggle() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
@@ -9327,7 +9424,7 @@ async fn interrupt_restores_queued_messages_into_composer() {
}
#[tokio::test]
async fn interrupt_replays_queued_slash_commands_and_restores_drafts() {
async fn interrupt_restores_queued_slash_commands_into_composer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_task_running(true);
@@ -9345,19 +9442,22 @@ async fn interrupt_replays_queued_slash_commands_and_restores_drafts() {
}),
});
assert_eq!(chat.bottom_pane.composer_text(), "queued draft");
assert!(chat.has_active_view(), "expected /review popup to open");
assert_eq!(chat.bottom_pane.composer_text(), "queued draft\n/review");
assert!(
!chat.has_active_view(),
"expected interrupt restore to keep queued slash drafts in the composer"
);
assert!(chat.queued_user_messages.is_empty());
assert!(
op_rx.try_recv().is_err(),
"unexpected outbound op after bare /review interrupt replay"
"unexpected outbound op after interrupt restore"
);
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
async fn interrupt_replays_multiple_queued_slash_commands_in_order() {
async fn interrupt_restores_multiple_queued_slash_commands_into_composer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.bottom_pane.set_task_running(true);
@@ -9375,19 +9475,17 @@ async fn interrupt_replays_multiple_queued_slash_commands_in_order() {
}),
});
let inserted = drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.composer_text(), "/fast status\n/review");
assert!(
inserted
.iter()
.any(|cell| lines_to_single_string(cell).contains("Fast mode is off.")),
"expected /fast status to run before /review popup"
!chat.has_active_view(),
"expected interrupt restore to keep slash drafts editable"
);
assert!(chat.has_active_view(), "expected /review popup to open");
assert!(chat.queued_user_messages.is_empty());
assert!(
op_rx.try_recv().is_err(),
"unexpected outbound op after slash-only interrupt replay"
"unexpected outbound op after slash-only interrupt restore"
);
let _ = drain_insert_history(&mut rx);
}
#[tokio::test]
@@ -11379,20 +11477,14 @@ async fn enter_queues_user_messages_while_review_is_running() {
}
#[tokio::test]
async fn review_slash_command_queues_while_task_running() {
async fn review_slash_command_opens_popup_while_task_running() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.dispatch_command(SlashCommand::Review);
assert_eq!(
chat.queued_user_message_texts(),
vec!["/review".to_string()]
);
assert!(
!chat.has_active_view(),
"expected /review to queue instead of opening a popup"
);
assert!(chat.queued_user_messages.is_empty());
assert!(chat.has_active_view(), "expected /review popup to open");
assert!(drain_insert_history(&mut rx).is_empty());
}
@@ -11478,6 +11570,48 @@ async fn queued_review_selection_replays_after_turn_complete() {
}
}
#[tokio::test]
async fn queued_commit_review_selection_preserves_title_after_turn_complete() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.on_task_started();
chat.handle_serialized_slash_command(ChatWidget::review_request_draft(&ReviewRequest {
target: ReviewTarget::Commit {
sha: "abc123".to_string(),
title: Some("Preserve commit subject".to_string()),
},
user_facing_hint: None,
}));
assert_eq!(
chat.queued_user_message_texts(),
vec!["/review commit abc123 Preserve commit subject".to_string()]
);
chat.on_task_complete(None, false);
loop {
match op_rx.try_recv() {
Ok(Op::Review { review_request }) => {
assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Commit {
sha: "abc123".to_string(),
title: Some("Preserve commit subject".to_string()),
},
user_facing_hint: None,
}
);
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected queued /review commit op"),
Err(TryRecvError::Disconnected) => panic!("expected queued /review commit op"),
}
}
}
#[tokio::test]
async fn review_queues_user_messages_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;