mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
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:
co-authored by
Codex
parent
35e8aa9ce0
commit
fa50564579
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
+22
@@ -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.
|
||||
-22
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user