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
2026-04-19 10:52:16 -07:00
committed by GitHub
Unverified
parent 116317021d
commit 917a85b0d6
24 changed files with 1428 additions and 206 deletions
+284 -3
View File
@@ -239,6 +239,7 @@ pub enum InputResult {
Queued {
text: String,
text_elements: Vec<TextElement>,
action: QueuedInputAction,
},
/// A bare slash command parsed by the composer.
///
@@ -254,6 +255,13 @@ pub enum InputResult {
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueuedInputAction {
Plain,
ParseSlash,
RunShell,
}
#[derive(Clone, Debug, PartialEq)]
struct AttachedImage {
placeholder: String,
@@ -398,6 +406,12 @@ enum ActivePopup {
Skill(SkillPopup),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashValidation {
Immediate,
Deferred,
}
const FOOTER_SPACING_HEIGHT: u16 = 0;
impl ChatComposer {
@@ -1389,14 +1403,48 @@ impl ChatComposer {
// before applying completion.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
if let Some(sel) = popup.selected_item() {
let selected_cmd = popup.selected_item().map(|sel| {
let CommandItem::Builtin(cmd) = sel;
cmd
});
if let Some(cmd) = selected_cmd {
if cmd == SlashCommand::Skills {
self.stage_selected_slash_command_history(cmd);
self.textarea.set_text_clearing_elements("");
return (InputResult::Command(cmd), true);
}
let selected_command_text = format!("/{}", cmd.command());
let starts_with_cmd =
first_line.trim_start().starts_with(&selected_command_text);
if !starts_with_cmd {
self.textarea
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
if !self.textarea.text().is_empty() {
self.textarea.set_cursor(self.textarea.text().len());
}
return (InputResult::None, true);
}
}
if self.is_task_running {
return self.handle_submission(/*should_queue*/ true);
}
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
..
} => {
// Treat "/" as accepting the highlighted command as text completion
// while the slash-command popup is active.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
let selected_cmd = popup.selected_item().map(|sel| {
let CommandItem::Builtin(cmd) = sel;
cmd
});
if let Some(cmd) = selected_cmd {
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
@@ -2169,6 +2217,14 @@ impl ChatComposer {
fn prepare_submission_text(
&mut self,
record_history: bool,
) -> Option<(String, Vec<TextElement>)> {
self.prepare_submission_text_with_options(record_history, SlashValidation::Immediate)
}
fn prepare_submission_text_with_options(
&mut self,
record_history: bool,
slash_validation: SlashValidation,
) -> Option<(String, Vec<TextElement>)> {
let mut text = self.textarea.text().to_string();
let original_input = text.clone();
@@ -2199,7 +2255,8 @@ impl ChatComposer {
text = text.trim().to_string();
text_elements = Self::trim_text_elements(&expanded_input, &text, text_elements);
if self.slash_commands_enabled()
if slash_validation == SlashValidation::Immediate
&& self.slash_commands_enabled()
&& let Some((name, _rest, _rest_offset)) = parse_slash_name(&text)
{
let treat_as_plain_text = input_starts_with_space || name.contains('/');
@@ -2282,6 +2339,31 @@ impl ChatComposer {
should_queue: bool,
now: Instant,
) -> (InputResult, bool) {
if should_queue {
let raw_text = self.textarea.text();
let defer_slash_validation =
self.should_parse_as_slash_on_dequeue_from_raw_text(raw_text);
if let Some((text, text_elements)) = self.prepare_submission_text_with_options(
/*record_history*/ true,
if defer_slash_validation {
SlashValidation::Deferred
} else {
SlashValidation::Immediate
},
) {
let action = self.queued_input_action(&text, defer_slash_validation);
return (
InputResult::Queued {
text,
text_elements,
action,
},
true,
);
}
return (InputResult::None, true);
}
// If the first line is a bare built-in slash command (no args),
// dispatch it even when the slash popup isn't visible. This preserves
// the workflow: type a prefix ("/di"), press Tab to complete to
@@ -2346,6 +2428,7 @@ impl ChatComposer {
InputResult::Queued {
text,
text_elements,
action: QueuedInputAction::Plain,
},
true,
)
@@ -2477,6 +2560,24 @@ impl ChatComposer {
true
}
fn should_parse_as_slash_on_dequeue_from_raw_text(&self, text: &str) -> bool {
self.slash_commands_enabled() && !text.starts_with(' ') && text.trim().starts_with('/')
}
fn queued_input_action(
&self,
prepared_text: &str,
defer_slash_validation: bool,
) -> QueuedInputAction {
if defer_slash_validation && prepared_text.starts_with('/') {
QueuedInputAction::ParseSlash
} else if prepared_text.starts_with('!') {
QueuedInputAction::RunShell
} else {
QueuedInputAction::Plain
}
}
/// Stage the current slash-command text for later local recall.
///
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
@@ -2690,7 +2791,9 @@ impl ChatComposer {
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
..
} if !self.is_bang_shell_command() => self.handle_submission(self.is_task_running),
} if self.is_task_running || !self.is_bang_shell_command() => {
self.handle_submission(self.is_task_running)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
@@ -6543,6 +6646,131 @@ mod tests {
assert!(found_error, "expected error history cell to be sent");
}
#[test]
fn tab_queues_slash_led_prompts_while_task_running_without_validation() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
fn assert_queued_slash(input: &str) {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*running*/ true);
composer.textarea.set_text_clearing_elements(input);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
match result {
InputResult::Queued {
text,
text_elements,
action,
} => {
assert_eq!(text, input);
assert!(text_elements.is_empty());
assert_eq!(action, QueuedInputAction::ParseSlash);
}
other => panic!("expected slash-led input to queue, got {other:?}"),
}
assert!(composer.textarea.is_empty());
assert!(
rx.try_recv().is_err(),
"queueing should not report slash errors"
);
}
assert_queued_slash("/compact");
assert_queued_slash("/review check regressions");
assert_queued_slash("/fast on");
assert_queued_slash("/does-not-exist");
}
#[test]
fn tab_queues_leading_space_slash_as_plain_text_while_task_running() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*running*/ true);
composer
.textarea
.set_text_clearing_elements(" /does-not-exist");
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
match result {
InputResult::Queued { text, action, .. } => {
assert_eq!(text, "/does-not-exist");
assert_eq!(action, QueuedInputAction::Plain);
}
other => panic!("expected leading-space slash input to queue, got {other:?}"),
}
}
#[test]
fn tab_queues_bang_shell_prompts_while_task_running_without_execution() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
fn assert_queued_shell(input: &str, expected_text: &str) {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*running*/ true);
composer.textarea.set_text_clearing_elements(input);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
match result {
InputResult::Queued {
text,
text_elements,
action,
} => {
assert_eq!(text, expected_text);
assert!(text_elements.is_empty());
assert_eq!(action, QueuedInputAction::RunShell);
}
other => panic!("expected bang shell input to queue, got {other:?}"),
}
assert!(composer.textarea.is_empty());
assert!(
rx.try_recv().is_err(),
"queueing should not show shell help immediately"
);
}
assert_queued_shell("!echo hi", "!echo hi");
assert_queued_shell("!", "!");
assert_queued_shell(" !echo hi", "!echo hi");
}
#[test]
fn slash_tab_completion_moves_cursor_to_end() {
use crossterm::event::KeyCode;
@@ -6568,6 +6796,59 @@ mod tests {
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
}
#[test]
fn slash_tab_completion_wins_over_queueing_while_task_running() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_task_running(/*running*/ true);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.textarea.text(), "/model ");
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
}
#[test]
fn slash_key_completes_selected_slash_command_as_text() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
type_chars_humanlike(&mut composer, &['/', 'm']);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.textarea.text(), "/model ");
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
}
#[test]
fn slash_tab_then_enter_dispatches_builtin_command() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
@@ -1273,6 +1273,7 @@ impl McpServerElicitationOverlay {
| InputResult::Queued {
text,
text_elements,
..
} => {
self.apply_submission_to_draft(text, text_elements);
self.validation_error = None;
+3 -2
View File
@@ -84,10 +84,10 @@ mod file_search_popup;
mod footer;
mod list_selection_view;
mod memories_settings_view;
mod prompt_args;
pub(crate) mod prompt_args;
mod skill_popup;
mod skills_toggle_view;
mod slash_commands;
pub(crate) mod slash_commands;
pub(crate) use footer::CollaborationModeIndicator;
pub(crate) use list_selection_view::ColumnWidthMode;
pub(crate) use list_selection_view::SelectionRowDisplay;
@@ -154,6 +154,7 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
pub(crate) use chat_composer::QueuedInputAction;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
@@ -10,14 +10,14 @@ use crate::render::renderable::Renderable;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
/// Widget that displays pending steers plus follow-up messages held while a turn is in progress.
/// Widget that displays pending steers plus follow-up inputs held while a turn is in progress.
///
/// The widget renders pending steers first, then rejected steers that will be
/// resubmitted at end of turn, then ordinary queued user messages. Pending
/// steers explain that they will be submitted after the next tool/result
/// boundary unless the user presses Esc to interrupt and send them
/// immediately. The edit hint at the bottom only appears when there are actual
/// queued user messages to pop back into the composer. Because some terminals
/// queued user inputs to pop back into the composer. Because some terminals
/// intercept certain modifier-key combinations, the displayed binding is
/// configurable via [`set_edit_binding`](Self::set_edit_binding).
pub(crate) struct PendingInputPreview {
@@ -128,7 +128,7 @@ impl PendingInputPreview {
if !lines.is_empty() {
lines.push(Line::from(""));
}
Self::push_section_header(&mut lines, width, "Queued follow-up messages".into());
Self::push_section_header(&mut lines, width, "Queued follow-up inputs".into());
for message in &self.queued_messages {
let wrapped = adaptive_wrap_lines(
@@ -933,6 +933,7 @@ impl RequestUserInputOverlay {
| InputResult::Queued {
text,
text_elements,
..
} => {
if self.has_options()
&& matches!(self.focus, Focus::Notes)
@@ -1,11 +1,12 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 282
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 6 },
content: [
"• Queued follow-up messages ",
"• Queued follow-up inputs ",
" ↳ This is ",
" a message ",
" with many ",
@@ -1,11 +1,12 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 253
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 6 },
content: [
"• Queued follow-up messages ",
"• Queued follow-up inputs ",
" ↳ Hello, world! ",
" ↳ This is another message ",
" ↳ This is a third message ",
@@ -1,11 +1,12 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 204
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 3 },
content: [
"• Queued follow-up messages ",
"• Queued follow-up inputs ",
" ↳ Hello, world! ",
" ⌥ + ↑ edit last queued message ",
],
@@ -1,11 +1,12 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 216
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 3 },
content: [
"• Queued follow-up messages ",
"• Queued follow-up inputs ",
" ↳ Hello, world! ",
" shift + ← edit last queued message ",
],
@@ -1,5 +1,6 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 345
expression: "format!(\"{buf:?}\")"
---
Buffer {
@@ -13,7 +14,7 @@ Buffer {
"• Messages to be submitted at end of turn ",
" ↳ Rejected steer that will be retried. ",
" ",
"• Queued follow-up messages ",
"• Queued follow-up inputs ",
" ↳ Queued follow-up question ",
" ⌥ + ↑ edit last queued message ",
],
@@ -1,11 +1,12 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 233
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 4 },
content: [
"• Queued follow-up messages ",
"• Queued follow-up inputs ",
" ↳ Hello, world! ",
" ↳ This is another message ",
" ⌥ + ↑ edit last queued message ",
@@ -1,11 +1,12 @@
---
source: tui/src/bottom_pane/pending_input_preview.rs
assertion_line: 269
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 40, height: 5 },
content: [
"• Queued follow-up messages ",
"• Queued follow-up inputs ",
" ↳ This is a longer message that should",
" be wrapped ",
" ↳ This is another message ",
@@ -1,8 +1,9 @@
---
source: tui/src/bottom_pane/mod.rs
assertion_line: 1858
expression: "render_snapshot(&pane, area)"
---
• Queued follow-up messages
• Queued follow-up inputs
↳ Queued follow-up question
⌥ + ↑ edit last queued message
@@ -1,10 +1,11 @@
---
source: tui/src/bottom_pane/mod.rs
assertion_line: 1889
expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interrupt)
• Queued follow-up messages
• Queued follow-up inputs
↳ Queued follow-up question
⌥ + ↑ edit last queued message
@@ -1,12 +1,13 @@
---
source: tui/src/bottom_pane/mod.rs
assertion_line: 1826
expression: "render_snapshot(&pane, area)"
---
• Working (0s • esc to interrupt)
└ First detail line
Second detail line
• Queued follow-up messages
• Queued follow-up inputs
↳ Queued follow-up question
⌥ + ↑ edit last queued message
+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
+257 -67
View File
@@ -6,6 +6,23 @@
//! slash-command recall follows the same submitted-input rule as ordinary text.
use super::*;
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::slash_commands;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlashCommandDispatchSource {
Live,
Queued,
}
struct PreparedSlashCommandArgs {
args: String,
text_elements: Vec<TextElement>,
local_images: Vec<LocalImageAttachment>,
remote_image_urls: Vec<String>,
mention_bindings: Vec<MentionBinding>,
source: SlashCommandDispatchSource,
}
impl ChatWidget {
/// Dispatch a bare slash command and record its staged local-history entry.
@@ -370,7 +387,7 @@ impl ChatWidget {
&mut self,
cmd: SlashCommand,
args: String,
_text_elements: Vec<TextElement>,
text_elements: Vec<TextElement>,
) {
if !cmd.supports_inline_args() {
self.dispatch_command(cmd);
@@ -386,25 +403,60 @@ impl ChatWidget {
return;
}
let trimmed = args.trim();
if trimmed.is_empty() {
self.dispatch_command(cmd);
return;
}
let Some((prepared_args, prepared_elements)) =
self.prepare_live_inline_args(args, text_elements)
else {
return;
};
self.dispatch_prepared_command_with_args(
cmd,
PreparedSlashCommandArgs {
args: prepared_args,
text_elements: prepared_elements,
local_images: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
source: SlashCommandDispatchSource::Live,
},
);
}
fn prepare_live_inline_args(
&mut self,
args: String,
text_elements: Vec<TextElement>,
) -> Option<(String, Vec<TextElement>)> {
if self.bottom_pane.composer_text().is_empty() {
Some((args, text_elements))
} else {
self.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
}
}
fn dispatch_prepared_command_with_args(
&mut self,
cmd: SlashCommand,
prepared: PreparedSlashCommandArgs,
) {
let PreparedSlashCommandArgs {
args,
text_elements,
mut local_images,
mut remote_image_urls,
mut mention_bindings,
source,
} = prepared;
let trimmed = args.trim();
match cmd {
SlashCommand::Fast => {
if trimmed.is_empty() {
self.dispatch_command(cmd);
return;
}
let prepared_args = if self.bottom_pane.composer_text().is_empty() {
args
} else {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
prepared_args
};
match prepared_args.trim().to_ascii_lowercase().as_str() {
match trimmed.to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
"off" => self.set_service_tier_selection(/*service_tier*/ None),
"status" => {
@@ -427,40 +479,29 @@ impl ChatWidget {
SlashCommand::Rename if !trimmed.is_empty() => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args)
else {
let Some(name) = crate::legacy_core::util::normalize_thread_name(&args) else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
};
self.app_event_tx.set_thread_name(name);
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Plan if !trimmed.is_empty() => {
if !self.apply_plan_slash_command() {
return;
}
let Some((prepared_args, prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
let local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
let remote_image_urls = self.take_remote_image_urls();
if source == SlashCommandDispatchSource::Live {
local_images = self
.bottom_pane
.take_recent_submission_images_with_placeholders();
remote_image_urls = self.take_remote_image_urls();
mention_bindings = self.bottom_pane.take_recent_submission_mention_bindings();
}
let user_message = UserMessage {
text: prepared_args,
text: args,
local_images,
remote_image_urls,
text_elements: prepared_elements,
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
text_elements,
mention_bindings,
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
@@ -472,45 +513,194 @@ impl ChatWidget {
}
}
SlashCommand::Review if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.submit_op(AppCommand::review(ReviewRequest {
target: ReviewTarget::Custom {
instructions: prepared_args,
},
target: ReviewTarget::Custom { instructions: args },
user_facing_hint: None,
}));
self.bottom_pane.drain_pending_submission_state();
}
SlashCommand::Resume if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::ResumeSessionByIdOrName(prepared_args));
self.bottom_pane.drain_pending_submission_state();
.send(AppEvent::ResumeSessionByIdOrName(args));
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
};
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
path: prepared_args,
});
self.bottom_pane.drain_pending_submission_state();
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
}
_ => self.dispatch_command(cmd),
}
if source == SlashCommandDispatchSource::Live {
self.bottom_pane.drain_pending_submission_state();
}
}
pub(super) fn submit_queued_slash_prompt(&mut self, user_message: UserMessage) -> QueueDrain {
let UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
} = user_message;
let Some((name, rest, rest_offset)) = parse_slash_name(&text) else {
self.submit_user_message(UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
});
return QueueDrain::Stop;
};
if name.contains('/') {
self.submit_user_message(UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
});
return QueueDrain::Stop;
}
let Some(cmd) = slash_commands::find_builtin_command(name, self.builtin_command_flags())
else {
self.add_info_message(
format!(
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
),
/*hint*/ None,
);
return QueueDrain::Continue;
};
if rest.is_empty() {
self.dispatch_command(cmd);
return self.queued_command_drain_result(cmd);
}
if !cmd.supports_inline_args() {
self.submit_user_message(UserMessage {
text,
local_images,
remote_image_urls,
text_elements,
mention_bindings,
});
return QueueDrain::Stop;
}
let args_elements = Self::slash_command_args_elements(rest, rest_offset, &text_elements);
self.dispatch_prepared_command_with_args(
cmd,
PreparedSlashCommandArgs {
args: rest.trim().to_string(),
text_elements: args_elements,
local_images,
remote_image_urls,
mention_bindings,
source: SlashCommandDispatchSource::Queued,
},
);
self.queued_command_drain_result(cmd)
}
fn builtin_command_flags(&self) -> slash_commands::BuiltinCommandFlags {
#[cfg(target_os = "windows")]
let allow_elevate_sandbox = {
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken)
};
#[cfg(not(target_os = "windows"))]
let allow_elevate_sandbox = false;
slash_commands::BuiltinCommandFlags {
collaboration_modes_enabled: self.collaboration_modes_enabled(),
connectors_enabled: self.connectors_enabled(),
plugins_command_enabled: self.config.features.enabled(Feature::Plugins),
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,
}
}
fn queued_command_drain_result(&self, cmd: SlashCommand) -> QueueDrain {
if self.is_user_turn_pending_or_running() || !self.bottom_pane.no_modal_or_popup_active() {
return QueueDrain::Stop;
}
match cmd {
SlashCommand::Fast
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Ps
| SlashCommand::Stop
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Plugins
| SlashCommand::Rollout
| SlashCommand::Copy
| SlashCommand::Diff
| SlashCommand::Rename
| SlashCommand::TestApproval => QueueDrain::Continue,
SlashCommand::Feedback
| SlashCommand::New
| SlashCommand::Clear
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Review
| SlashCommand::Model
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::Personality
| SlashCommand::Plan
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::MultiAgents
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Logout
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Title
| SlashCommand::Statusline
| SlashCommand::Theme => QueueDrain::Stop,
}
}
fn slash_command_args_elements(
rest: &str,
rest_offset: usize,
text_elements: &[TextElement],
) -> Vec<TextElement> {
if rest.is_empty() || text_elements.is_empty() {
return Vec::new();
}
text_elements
.iter()
.filter_map(|elem| {
if elem.byte_range.end <= rest_offset {
return None;
}
let start = elem.byte_range.start.saturating_sub(rest_offset);
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
if start >= rest.len() {
return None;
}
end = end.min(rest.len());
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
})
.collect()
}
}
@@ -1,10 +1,12 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
assertion_line: 2288
expression: normalize_snapshot_paths(term.backend().vt100().screen().contents())
---
• Working (0s • esc to interrupt)
• Queued follow-up messages
• Queued follow-up inputs
↳ Hello, world! 0
↳ Hello, world! 1
↳ Hello, world! 2
+1
View File
@@ -12,6 +12,7 @@ pub(super) use crate::app_event::RealtimeAudioDeviceKind;
pub(super) use crate::app_event_sender::AppEventSender;
pub(super) use crate::bottom_pane::LocalImageAttachment;
pub(super) use crate::bottom_pane::MentionBinding;
pub(super) use crate::bottom_pane::QueuedInputAction;
pub(super) use crate::chatwidget::realtime::RealtimeConversationPhase;
pub(super) use crate::history_cell::UserHistoryCell;
pub(super) use crate::legacy_core::config::Config;
@@ -604,13 +604,16 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.queued_user_messages.push_back(UserMessage {
text: "Implement the plan.".to_string(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: Vec::new(),
});
chat.queued_user_messages.push_back(
UserMessage {
text: "Implement the plan.".to_string(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: Vec::new(),
}
.into(),
);
chat.refresh_pending_input_preview();
chat.handle_codex_event(Event {
@@ -797,6 +800,7 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
pending_steers: VecDeque::new(),
rejected_steers_queue: VecDeque::new(),
queued_user_messages: VecDeque::new(),
user_turn_pending_start: false,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: true,
@@ -826,9 +830,9 @@ async fn alt_up_edits_most_recent_queued_message() {
// Seed two queued messages.
chat.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()));
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Press Alt+Up to edit the most recent (last) queued message.
@@ -1031,9 +1035,9 @@ async fn interrupt_restores_queued_messages_into_composer() {
// Queue two user messages while the task is running.
chat.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()));
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Deliver a TurnAborted event with Interrupted reason (as if Esc was pressed).
@@ -1073,9 +1077,9 @@ async fn interrupt_prepends_queued_messages_before_existing_composer_text() {
.set_composer_text("current draft".to_string(), Vec::new(), Vec::new());
chat.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()));
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
chat.handle_codex_event(Event {
+10 -1
View File
@@ -987,7 +987,7 @@ async fn user_shell_command_renders_output_not_exploring() {
}
#[tokio::test]
async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() {
async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let conversation_id = ThreadId::new();
let rollout_file = NamedTempFile::new().unwrap();
@@ -1015,6 +1015,15 @@ async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() {
});
drain_insert_history(&mut rx);
while op_rx.try_recv().is_ok() {}
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,
}),
});
chat.bottom_pane
.set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new());
+3 -2
View File
@@ -255,6 +255,7 @@ pub(super) async fn make_chatwidget_manual(
show_welcome_banner: true,
startup_tooltip_override: 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,
@@ -726,9 +727,9 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi
// Seed two queued messages.
chat.queued_user_messages
.push_back(UserMessage::from("first queued".to_string()));
.push_back(UserMessage::from("first queued".to_string()).into());
chat.queued_user_messages
.push_back(UserMessage::from("second queued".to_string()));
.push_back(UserMessage::from("second queued".to_string()).into());
chat.refresh_pending_input_preview();
// Press Shift+Left to edit the most recent (last) queued message.
@@ -29,26 +29,32 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
)];
let existing_images = vec![PathBuf::from("/tmp/existing.png")];
chat.queued_user_messages.push_back(UserMessage {
text: first_text,
local_images: vec![LocalImageAttachment {
placeholder: first_placeholder.to_string(),
path: first_images[0].clone(),
}],
remote_image_urls: Vec::new(),
text_elements: first_elements,
mention_bindings: Vec::new(),
});
chat.queued_user_messages.push_back(UserMessage {
text: second_text,
local_images: vec![LocalImageAttachment {
placeholder: second_placeholder.to_string(),
path: second_images[0].clone(),
}],
remote_image_urls: Vec::new(),
text_elements: second_elements,
mention_bindings: Vec::new(),
});
chat.queued_user_messages.push_back(
UserMessage {
text: first_text,
local_images: vec![LocalImageAttachment {
placeholder: first_placeholder.to_string(),
path: first_images[0].clone(),
}],
remote_image_urls: Vec::new(),
text_elements: first_elements,
mention_bindings: Vec::new(),
}
.into(),
);
chat.queued_user_messages.push_back(
UserMessage {
text: second_text,
local_images: vec![LocalImageAttachment {
placeholder: second_placeholder.to_string(),
path: second_images[0].clone(),
}],
remote_image_urls: Vec::new(),
text_elements: second_elements,
mention_bindings: Vec::new(),
}
.into(),
);
chat.refresh_pending_input_preview();
chat.bottom_pane
@@ -164,7 +170,7 @@ async fn steer_rejection_queues_review_follow_up_before_existing_queued_messages
});
let _ = drain_insert_history(&mut rx);
chat.queued_user_messages
.push_back(UserMessage::from("queued later"));
.push_back(UserMessage::from("queued later").into());
chat.submit_user_message(UserMessage::from("review follow-up one"));
chat.submit_user_message(UserMessage::from("review follow-up two"));
@@ -354,13 +360,14 @@ async fn restore_thread_input_state_restores_pending_steers_without_downgrading_
let mut rejected_steers_queue = VecDeque::new();
rejected_steers_queue.push_back(UserMessage::from("already rejected"));
let mut queued_user_messages = VecDeque::new();
queued_user_messages.push_back(UserMessage::from("queued draft"));
queued_user_messages.push_back(UserMessage::from("queued draft").into());
chat.restore_thread_input_state(Some(ThreadInputState {
composer: None,
pending_steers,
rejected_steers_queue,
queued_user_messages,
user_turn_pending_start: false,
current_collaboration_mode: chat.current_collaboration_mode.clone(),
active_collaboration_mask: chat.active_collaboration_mask.clone(),
task_running: false,
@@ -755,7 +762,7 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d
}
chat.queued_user_messages
.push_back(UserMessage::from("queued draft".to_string()));
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
chat.bottom_pane
.set_composer_text("still editing".to_string(), Vec::new(), Vec::new());
@@ -877,7 +884,7 @@ async fn manual_interrupt_restores_pending_steers_before_queued_messages() {
.set_composer_text("pending steer".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.queued_user_messages
.push_back(UserMessage::from("queued draft".to_string()));
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
match next_submit_op(&mut op_rx) {
@@ -919,7 +926,7 @@ async fn replaced_turn_clears_pending_steers_but_keeps_queued_drafts() {
.set_composer_text("pending steer".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.queued_user_messages
.push_back(UserMessage::from("queued draft".to_string()));
.push_back(UserMessage::from("queued draft".to_string()).into());
chat.refresh_pending_input_preview();
match next_submit_op(&mut op_rx) {
@@ -14,6 +14,13 @@ fn submit_composer_text(chat: &mut ChatWidget, text: &str) {
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
fn queue_composer_text_with_tab(chat: &mut ChatWidget, text: &str) {
chat.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
}
fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String {
@@ -51,6 +58,539 @@ async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn queued_slash_compact_dispatches_after_active_turn() {
let (mut chat, mut rx, _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,
}),
});
queue_composer_text_with_tab(&mut chat, "/compact");
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().action,
QueuedInputAction::ParseSlash
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::CodexOp(Op::Compact))),
"expected queued /compact to submit compact op; events: {events:?}"
);
}
#[tokio::test]
async fn queued_slash_review_with_args_dispatches_after_active_turn() {
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,
}),
});
queue_composer_text_with_tab(&mut chat, "/review check regressions");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
match op_rx.try_recv() {
Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() {
Ok(Op::Review { review_request }) => assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "check regressions".to_string(),
},
user_facing_hint: None,
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
},
Ok(Op::Review { review_request }) => assert_eq!(
review_request,
ReviewRequest {
target: ReviewTarget::Custom {
instructions: "check regressions".to_string(),
},
user_facing_hint: None,
}
),
other => panic!("expected queued /review to submit review op, got {other:?}"),
}
}
#[tokio::test]
async fn queued_slash_review_with_args_restores_for_edit() {
let (mut chat, _rx, _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,
}),
});
queue_composer_text_with_tab(&mut chat, "/review check regressions");
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert_eq!(
chat.bottom_pane.composer_text(),
"/review check regressions"
);
}
#[tokio::test]
async fn queued_bang_shell_dispatches_after_active_turn() {
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,
}),
});
queue_composer_text_with_tab(&mut chat, "!echo hi");
assert_eq!(chat.queued_user_messages.len(), 1);
assert_eq!(
chat.queued_user_messages.front().unwrap().action,
QueuedInputAction::RunShell
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
match op_rx.try_recv() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected queued shell command op, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_input() {
let (mut chat, mut 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,
}),
});
queue_composer_text_with_tab(&mut chat, "!");
queue_composer_text_with_tab(&mut chat, "hello after help");
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains(USER_SHELL_COMMAND_HELP_TITLE),
"expected delayed shell help, got {rendered:?}"
);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after help".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after empty shell command, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() {
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,
}),
});
queue_composer_text_with_tab(&mut chat, "!echo hi");
queue_composer_text_with_tab(&mut chat, "hello after shell");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
match op_rx.try_recv() {
Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"),
other => panic!("expected queued shell command op, got {other:?}"),
}
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert_eq!(chat.queued_user_messages.len(), 1);
let begin = begin_exec_with_source(
&mut chat,
"user-shell-echo",
"echo hi",
ExecCommandSource::UserShell,
);
end_exec(&mut chat, begin, "hi\n", "", /*exit_code*/ 0);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after shell".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after shell completion, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).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,
}),
});
queue_composer_text_with_tab(&mut chat, command);
queue_composer_text_with_tab(&mut chat, "hello after menu");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
assert_eq!(chat.queued_user_messages.len(), 1);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains(expected_popup_text),
"expected {command} menu to open; popup:\n{popup}"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after menu".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after cancelling {command}, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_slash_menu_cancel_drains_next_input() {
assert_cancelled_queued_menu_drains_next_input("/model", "Select Model").await;
assert_cancelled_queued_menu_drains_next_input("/permissions", "Update Model Permissions")
.await;
}
#[tokio::test]
async fn queued_slash_menu_selection_drains_next_input() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5-codex")).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,
}),
});
queue_composer_text_with_tab(&mut chat, "/permissions");
queue_composer_text_with_tab(&mut chat, "hello after selection");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Update Model Permissions"),
"expected permissions menu to open; popup:\n{popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after selection".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after permissions selection, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_bare_rename_drains_next_input_after_name_update() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
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,
}),
});
queue_composer_text_with_tab(&mut chat, "/rename");
queue_composer_text_with_tab(&mut chat, "hello after rename");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
assert_eq!(chat.queued_user_messages.len(), 1);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Name thread"));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_paste("Queued rename".to_string());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename"
)),
"expected rename prompt to submit thread name; events: {events:?}"
);
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Queued rename".to_string()),
}),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after rename".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message after /rename, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_inline_rename_does_not_drain_again_before_turn_started() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
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,
}),
});
queue_composer_text_with_tab(&mut chat, "/rename Queued rename");
queue_composer_text_with_tab(&mut chat, "first after rename");
queue_composer_text_with_tab(&mut chat, "second after rename");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::SetThreadName { name }) if name == "Queued rename"
)),
"expected queued /rename to submit thread name; events: {events:?}"
);
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "first after rename".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected first queued message after /rename, got {other:?}"),
}
assert_matches!(
op_rx.try_recv(),
Ok(Op::AddToHistory { text }) if text == "first after rename"
);
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
);
let input_state = chat.capture_thread_input_state().unwrap();
assert!(input_state.user_turn_pending_start);
chat.restore_thread_input_state(/*input_state*/ None);
assert!(!chat.user_turn_pending_start);
chat.restore_thread_input_state(Some(input_state));
assert!(chat.user_turn_pending_start);
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
);
chat.handle_codex_event(Event {
id: "rename".into(),
msg: EventMsg::ThreadNameUpdated(codex_protocol::protocol::ThreadNameUpdatedEvent {
thread_id,
thread_name: Some("Queued rename".to_string()),
}),
});
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
assert_eq!(
chat.queued_user_message_texts(),
vec!["second after rename"]
);
chat.handle_codex_event(Event {
id: "turn-2-start".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-2".to_string(),
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
}),
});
chat.handle_codex_event(Event {
id: "turn-2-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-2", Some("done"))),
});
match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "second after rename".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected second queued message after turn complete, got {other:?}"),
}
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn queued_unknown_slash_reports_error_when_dequeued() {
let (mut chat, mut rx, _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,
}),
});
queue_composer_text_with_tab(&mut chat, "/does-not-exist");
assert!(drain_insert_history(&mut rx).is_empty());
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let cells = drain_insert_history(&mut rx);
let rendered = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("Unrecognized command '/does-not-exist'"),
"expected delayed slash error, got {rendered:?}"
);
assert!(chat.queued_user_messages.is_empty());
}
#[tokio::test]
async fn ctrl_d_quits_without_prompt() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -825,6 +1365,58 @@ async fn user_turn_carries_service_tier_after_fast_toggle() {
}
}
#[tokio::test]
async fn queued_fast_slash_applies_before_next_queued_message() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.thread_id = Some(ThreadId::new());
set_chatgpt_auth(&mut chat);
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
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,
}),
});
queue_composer_text_with_tab(&mut chat, "/fast on");
queue_composer_text_with_tab(&mut chat, "hello after fast");
chat.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(turn_complete_event("turn-1", Some("done"))),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events.iter().any(|event| matches!(
event,
AppEvent::CodexOp(Op::OverrideTurnContext {
service_tier: Some(Some(ServiceTier::Fast)),
..
})
)),
"expected queued /fast to update service tier before next turn; events: {events:?}"
);
match next_submit_op(&mut op_rx) {
Op::UserTurn {
items,
service_tier: Some(Some(ServiceTier::Fast)),
..
} => assert_eq!(
items,
vec![UserInput::Text {
text: "hello after fast".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued message to submit with fast tier, got {other:?}"),
}
}
#[tokio::test]
async fn user_turn_clears_service_tier_after_fast_is_turned_off() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;