mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
queue slash commands in tui
Allow slash commands entered during a running turn to be queued and replayed after the turn completes, including /review and inline slash-command variants tested in codex-tui. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
co-authored by
Codex
parent
029aab5563
commit
9478b34e55
@@ -572,6 +572,7 @@ impl ChatComposer {
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
|
||||
let elements = self.current_mention_elements();
|
||||
let mut ordered = Vec::new();
|
||||
@@ -2525,9 +2526,6 @@ impl ChatComposer {
|
||||
&& let Some(cmd) =
|
||||
slash_commands::find_builtin_command(name, self.builtin_command_flags())
|
||||
{
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
Some(InputResult::Command(cmd))
|
||||
} else {
|
||||
@@ -2556,9 +2554,6 @@ impl ChatComposer {
|
||||
if !cmd.supports_inline_args() {
|
||||
return None;
|
||||
}
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
|
||||
let mut args_elements =
|
||||
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
|
||||
@@ -2573,10 +2568,10 @@ impl ChatComposer {
|
||||
|
||||
/// Expand pending placeholders and extract normalized inline-command args.
|
||||
///
|
||||
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
|
||||
/// not consume user input. Once a command is accepted, this helper performs the usual
|
||||
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
|
||||
/// full-text offsets to command-arg offsets.
|
||||
/// Inline-arg commands are initially dispatched using the raw draft so command-specific
|
||||
/// handling can decide whether to consume the input. Once a command is accepted, this helper
|
||||
/// performs the usual submission preparation (paste expansion, element trimming) and rebases
|
||||
/// element ranges from full-text offsets to command-arg offsets.
|
||||
pub(crate) fn prepare_inline_args_submission(
|
||||
&mut self,
|
||||
record_history: bool,
|
||||
@@ -2593,20 +2588,6 @@ impl ChatComposer {
|
||||
Some((trimmed_rest.to_string(), args_elements))
|
||||
}
|
||||
|
||||
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
|
||||
if !self.is_task_running || cmd.available_during_task() {
|
||||
return false;
|
||||
}
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
cmd.command()
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(message),
|
||||
)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Translate full-text element ranges into command-argument ranges.
|
||||
///
|
||||
/// `rest_offset` is the byte offset where `rest` begins in the full text.
|
||||
@@ -6678,7 +6659,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_disabled_while_task_running_keeps_text() {
|
||||
fn slash_command_while_task_running_still_dispatches() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -6700,24 +6681,16 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!(
|
||||
InputResult::CommandWithArgs(
|
||||
SlashCommand::Review,
|
||||
"these changes".to_string(),
|
||||
Vec::new(),
|
||||
),
|
||||
result
|
||||
);
|
||||
assert_eq!("/review these changes", composer.textarea.text());
|
||||
|
||||
let mut found_error = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
let message = cell
|
||||
.display_lines(80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(message.contains("disabled while a task is in progress"));
|
||||
found_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_error, "expected error history cell to be sent");
|
||||
assert!(rx.try_recv().is_err(), "no error should be emitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -263,6 +263,7 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
|
||||
self.composer.take_mention_bindings()
|
||||
}
|
||||
@@ -272,6 +273,7 @@ impl BottomPane {
|
||||
}
|
||||
|
||||
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn drain_pending_submission_state(&mut self) {
|
||||
let _ = self.take_recent_submission_images_with_placeholders();
|
||||
let _ = self.take_remote_image_urls();
|
||||
|
||||
+338
-77
@@ -148,6 +148,7 @@ use codex_protocol::protocol::WebSearchBeginEvent;
|
||||
use codex_protocol::protocol::WebSearchEndEvent;
|
||||
use codex_protocol::request_permissions::RequestPermissionsEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::user_input::ByteRange;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_sleep_inhibitor::SleepInhibitor;
|
||||
@@ -725,8 +726,8 @@ pub(crate) struct ChatWidget {
|
||||
// When resuming an existing session (selected via resume picker), avoid an
|
||||
// immediate redraw on SessionConfigured to prevent a gratuitous UI flicker.
|
||||
suppress_session_configured_redraw: bool,
|
||||
// User messages queued while a turn is in progress
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// User messages or slash-command actions queued while a turn is in progress.
|
||||
queued_user_messages: VecDeque<QueuedInput>,
|
||||
// Steers already submitted to core but not yet committed into history.
|
||||
//
|
||||
// The bottom pane shows these above queued drafts until core records the
|
||||
@@ -866,11 +867,72 @@ impl ThreadComposerState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum QueuedInput {
|
||||
UserMessage(UserMessage),
|
||||
SlashCommand(QueuedSlashCommand),
|
||||
}
|
||||
|
||||
impl QueuedInput {
|
||||
fn preview_text(&self) -> String {
|
||||
match self {
|
||||
Self::UserMessage(message) => message.text.clone(),
|
||||
Self::SlashCommand(command) => command.preview_text(),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_restorable_user_message(self) -> UserMessage {
|
||||
match self {
|
||||
Self::UserMessage(message) => message,
|
||||
Self::SlashCommand(command) => command.into_restorable_user_message(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserMessage> for QueuedInput {
|
||||
fn from(message: UserMessage) -> Self {
|
||||
Self::UserMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct QueuedSlashCommand {
|
||||
draft: UserMessage,
|
||||
action: QueuedSlashCommandAction,
|
||||
}
|
||||
|
||||
impl QueuedSlashCommand {
|
||||
fn preview_text(&self) -> String {
|
||||
self.draft.text.clone()
|
||||
}
|
||||
|
||||
fn into_restorable_user_message(self) -> UserMessage {
|
||||
self.draft
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum QueuedSlashCommandAction {
|
||||
Command(SlashCommand),
|
||||
Fast(QueuedFastCommandAction),
|
||||
Plan(UserMessage),
|
||||
Rename(String),
|
||||
Review(ReviewRequest),
|
||||
SandboxReadRoot(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum QueuedFastCommandAction {
|
||||
On,
|
||||
Off,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ThreadInputState {
|
||||
composer: Option<ThreadComposerState>,
|
||||
pending_steers: VecDeque<UserMessage>,
|
||||
queued_user_messages: VecDeque<UserMessage>,
|
||||
queued_user_messages: VecDeque<QueuedInput>,
|
||||
current_collaboration_mode: CollaborationMode,
|
||||
active_collaboration_mask: Option<CollaborationModeMask>,
|
||||
agent_turn_running: bool,
|
||||
@@ -2233,7 +2295,11 @@ impl ChatWidget {
|
||||
.drain(..)
|
||||
.map(|steer| steer.user_message)
|
||||
.collect();
|
||||
to_merge.extend(self.queued_user_messages.drain(..));
|
||||
to_merge.extend(
|
||||
self.queued_user_messages
|
||||
.drain(..)
|
||||
.map(QueuedInput::into_restorable_user_message),
|
||||
);
|
||||
if !existing_message.text.is_empty()
|
||||
|| !existing_message.local_images.is_empty()
|
||||
|| !existing_message.remote_image_urls.is_empty()
|
||||
@@ -2318,7 +2384,11 @@ impl ChatWidget {
|
||||
self.bottom_pane.set_composer_pending_pastes(Vec::new());
|
||||
}
|
||||
self.pending_steers.clear();
|
||||
self.queued_user_messages = input_state.pending_steers;
|
||||
self.queued_user_messages = input_state
|
||||
.pending_steers
|
||||
.into_iter()
|
||||
.map(QueuedInput::from)
|
||||
.collect();
|
||||
self.queued_user_messages
|
||||
.extend(input_state.queued_user_messages);
|
||||
} else {
|
||||
@@ -4110,8 +4180,8 @@ impl ChatWidget {
|
||||
&& self.queued_message_edit_binding.is_press(key_event)
|
||||
&& !self.queued_user_messages.is_empty()
|
||||
{
|
||||
if let Some(user_message) = self.queued_user_messages.pop_back() {
|
||||
self.restore_user_message_to_composer(user_message);
|
||||
if let Some(queued_input) = self.queued_user_messages.pop_back() {
|
||||
self.restore_user_message_to_composer(queued_input.into_restorable_user_message());
|
||||
self.refresh_pending_input_preview();
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -4284,14 +4354,11 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn dispatch_command(&mut self, cmd: SlashCommand) {
|
||||
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
cmd.command()
|
||||
);
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
self.request_redraw();
|
||||
if self.bottom_pane.is_task_running() && !cmd.available_during_task() {
|
||||
self.queue_slash_command(QueuedSlashCommand {
|
||||
draft: format!("/{}", cmd.command()).into(),
|
||||
action: QueuedSlashCommandAction::Command(cmd),
|
||||
});
|
||||
return;
|
||||
}
|
||||
match cmd {
|
||||
@@ -4622,27 +4689,49 @@ impl ChatWidget {
|
||||
self.dispatch_command(cmd);
|
||||
return;
|
||||
}
|
||||
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
cmd.command()
|
||||
);
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
let trimmed = args.trim();
|
||||
let should_queue = self.bottom_pane.is_task_running() && !cmd.available_during_task();
|
||||
match cmd {
|
||||
SlashCommand::Fast => {
|
||||
if trimmed.is_empty() {
|
||||
self.dispatch_command(cmd);
|
||||
if should_queue {
|
||||
self.queue_current_inline_bare_slash_command(cmd);
|
||||
} else {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match trimmed.to_ascii_lowercase().as_str() {
|
||||
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
|
||||
"off" => self.set_service_tier_selection(None),
|
||||
"status" => {
|
||||
let action = match trimmed.to_ascii_lowercase().as_str() {
|
||||
"on" => QueuedFastCommandAction::On,
|
||||
"off" => QueuedFastCommandAction::Off,
|
||||
"status" => QueuedFastCommandAction::Status,
|
||||
_ => {
|
||||
self.add_error_message("Usage: /fast [on|off|status]".to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
if should_queue {
|
||||
let Some((prepared_args, prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let args_message = self
|
||||
.take_prepared_submission_user_message(prepared_args, prepared_elements);
|
||||
let draft = Self::queued_inline_slash_command_draft(cmd, args_message);
|
||||
self.queue_slash_command(QueuedSlashCommand {
|
||||
draft,
|
||||
action: QueuedSlashCommandAction::Fast(action),
|
||||
});
|
||||
return;
|
||||
}
|
||||
match action {
|
||||
QueuedFastCommandAction::On => {
|
||||
self.set_service_tier_selection(Some(ServiceTier::Fast))
|
||||
}
|
||||
QueuedFastCommandAction::Off => self.set_service_tier_selection(None),
|
||||
QueuedFastCommandAction::Status => {
|
||||
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
|
||||
{
|
||||
"on"
|
||||
@@ -4651,89 +4740,249 @@ impl ChatWidget {
|
||||
};
|
||||
self.add_info_message(format!("Fast mode is {status}."), None);
|
||||
}
|
||||
_ => {
|
||||
self.add_error_message("Usage: /fast [on|off|status]".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
SlashCommand::Rename if !trimmed.is_empty() => {
|
||||
self.session_telemetry
|
||||
.counter("codex.thread.rename", 1, &[]);
|
||||
let Some((prepared_args, _prepared_elements)) =
|
||||
let Some((prepared_args, prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let args_message = self.take_prepared_submission_user_message(
|
||||
prepared_args.clone(),
|
||||
prepared_elements,
|
||||
);
|
||||
let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else {
|
||||
self.add_error_message("Thread name cannot be empty.".to_string());
|
||||
return;
|
||||
};
|
||||
if should_queue {
|
||||
let draft = Self::queued_inline_slash_command_draft(cmd, args_message);
|
||||
self.queue_slash_command(QueuedSlashCommand {
|
||||
draft,
|
||||
action: QueuedSlashCommandAction::Rename(name),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cell = Self::rename_confirmation_cell(&name, self.thread_id);
|
||||
self.add_boxed_history(Box::new(cell));
|
||||
self.request_redraw();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
SlashCommand::Plan if !trimmed.is_empty() => {
|
||||
self.dispatch_command(cmd);
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
return;
|
||||
}
|
||||
let Some((prepared_args, prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(true)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
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 {
|
||||
text: prepared_args,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
text_elements: prepared_elements,
|
||||
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
|
||||
};
|
||||
if self.is_session_configured() {
|
||||
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 user_message =
|
||||
self.take_prepared_submission_user_message(prepared_args, prepared_elements);
|
||||
if should_queue {
|
||||
let draft = Self::queued_inline_slash_command_draft(cmd, user_message.clone());
|
||||
self.queue_slash_command(QueuedSlashCommand {
|
||||
draft,
|
||||
action: QueuedSlashCommandAction::Plan(user_message),
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.dispatch_command(cmd);
|
||||
if self.active_mode_kind() != ModeKind::Plan {
|
||||
return;
|
||||
}
|
||||
self.submit_plan_user_message(user_message);
|
||||
}
|
||||
SlashCommand::Review if !trimmed.is_empty() => {
|
||||
let Some((prepared_args, _prepared_elements)) =
|
||||
let Some((prepared_args, prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.submit_op(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: prepared_args,
|
||||
},
|
||||
user_facing_hint: None,
|
||||
let args_message = self.take_prepared_submission_user_message(
|
||||
prepared_args.clone(),
|
||||
prepared_elements,
|
||||
);
|
||||
let review_request = ReviewRequest {
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: prepared_args,
|
||||
},
|
||||
});
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
user_facing_hint: None,
|
||||
};
|
||||
if should_queue {
|
||||
let draft = Self::queued_inline_slash_command_draft(cmd, args_message);
|
||||
self.queue_slash_command(QueuedSlashCommand {
|
||||
draft,
|
||||
action: QueuedSlashCommandAction::Review(review_request),
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.submit_op(Op::Review { review_request });
|
||||
}
|
||||
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
||||
let Some((prepared_args, _prepared_elements)) =
|
||||
let Some((prepared_args, prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let args_message = self.take_prepared_submission_user_message(
|
||||
prepared_args.clone(),
|
||||
prepared_elements,
|
||||
);
|
||||
if should_queue {
|
||||
let draft = Self::queued_inline_slash_command_draft(cmd, args_message);
|
||||
self.queue_slash_command(QueuedSlashCommand {
|
||||
draft,
|
||||
action: QueuedSlashCommandAction::SandboxReadRoot(prepared_args),
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
|
||||
path: prepared_args,
|
||||
});
|
||||
self.bottom_pane.drain_pending_submission_state();
|
||||
}
|
||||
_ => self.dispatch_command(cmd),
|
||||
_ => {
|
||||
if should_queue && trimmed.is_empty() {
|
||||
self.queue_current_inline_bare_slash_command(cmd);
|
||||
} else {
|
||||
self.dispatch_command(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn take_prepared_submission_user_message(
|
||||
&mut self,
|
||||
text: String,
|
||||
text_elements: Vec<TextElement>,
|
||||
) -> UserMessage {
|
||||
UserMessage {
|
||||
text,
|
||||
local_images: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images_with_placeholders(),
|
||||
remote_image_urls: self.take_remote_image_urls(),
|
||||
text_elements,
|
||||
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
|
||||
}
|
||||
}
|
||||
|
||||
fn queued_inline_slash_command_draft(
|
||||
cmd: SlashCommand,
|
||||
args_message: UserMessage,
|
||||
) -> UserMessage {
|
||||
let prefix = format!("/{}", cmd.command());
|
||||
let UserMessage {
|
||||
text,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
text_elements,
|
||||
mention_bindings,
|
||||
} = args_message;
|
||||
|
||||
if text.is_empty() {
|
||||
return UserMessage {
|
||||
text: prefix,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
text_elements,
|
||||
mention_bindings,
|
||||
};
|
||||
}
|
||||
|
||||
let mut draft_text = format!("{prefix} ");
|
||||
let offset = draft_text.len();
|
||||
draft_text.push_str(&text);
|
||||
let text_elements = text_elements
|
||||
.into_iter()
|
||||
.map(|element| {
|
||||
let start = element.byte_range.start + offset;
|
||||
let end = element.byte_range.end + offset;
|
||||
element.map_range(|_| ByteRange { start, end })
|
||||
})
|
||||
.collect();
|
||||
|
||||
UserMessage {
|
||||
text: draft_text,
|
||||
local_images,
|
||||
remote_image_urls,
|
||||
text_elements,
|
||||
mention_bindings,
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_current_inline_bare_slash_command(&mut self, cmd: SlashCommand) {
|
||||
let Some((prepared_args, prepared_elements)) =
|
||||
self.bottom_pane.prepare_inline_args_submission(false)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let args_message =
|
||||
self.take_prepared_submission_user_message(prepared_args, prepared_elements);
|
||||
self.queue_slash_command(QueuedSlashCommand {
|
||||
draft: Self::queued_inline_slash_command_draft(cmd, args_message),
|
||||
action: QueuedSlashCommandAction::Command(cmd),
|
||||
});
|
||||
}
|
||||
|
||||
fn queue_slash_command(&mut self, command: QueuedSlashCommand) {
|
||||
self.queued_user_messages
|
||||
.push_back(QueuedInput::SlashCommand(command));
|
||||
self.refresh_pending_input_preview();
|
||||
}
|
||||
|
||||
fn submit_plan_user_message(&mut self, user_message: UserMessage) {
|
||||
if self.is_session_configured() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_queued_slash_command(&mut self, command: QueuedSlashCommand) {
|
||||
match command.action {
|
||||
QueuedSlashCommandAction::Command(cmd) => self.dispatch_command(cmd),
|
||||
QueuedSlashCommandAction::Fast(action) => match action {
|
||||
QueuedFastCommandAction::On => {
|
||||
self.set_service_tier_selection(Some(ServiceTier::Fast))
|
||||
}
|
||||
QueuedFastCommandAction::Off => self.set_service_tier_selection(None),
|
||||
QueuedFastCommandAction::Status => {
|
||||
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
|
||||
"on"
|
||||
} else {
|
||||
"off"
|
||||
};
|
||||
self.add_info_message(format!("Fast mode is {status}."), None);
|
||||
}
|
||||
},
|
||||
QueuedSlashCommandAction::Plan(user_message) => {
|
||||
self.dispatch_command(SlashCommand::Plan);
|
||||
if self.active_mode_kind() == ModeKind::Plan {
|
||||
self.submit_plan_user_message(user_message);
|
||||
}
|
||||
}
|
||||
QueuedSlashCommandAction::Rename(name) => {
|
||||
self.session_telemetry
|
||||
.counter("codex.thread.rename", 1, &[]);
|
||||
let cell = Self::rename_confirmation_cell(&name, self.thread_id);
|
||||
self.add_boxed_history(Box::new(cell));
|
||||
self.request_redraw();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::CodexOp(Op::SetThreadName { name }));
|
||||
}
|
||||
QueuedSlashCommandAction::Review(review_request) => {
|
||||
self.submit_op(Op::Review { review_request });
|
||||
}
|
||||
QueuedSlashCommandAction::SandboxReadRoot(path) => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4824,7 +5073,7 @@ impl ChatWidget {
|
||||
|| self.bottom_pane.is_task_running()
|
||||
|| self.is_review_mode
|
||||
{
|
||||
self.queued_user_messages.push_back(user_message);
|
||||
self.queued_user_messages.push_back(user_message.into());
|
||||
self.refresh_pending_input_preview();
|
||||
} else {
|
||||
self.submit_user_message(user_message);
|
||||
@@ -4834,12 +5083,12 @@ impl ChatWidget {
|
||||
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(user_message.into());
|
||||
self.refresh_pending_input_preview();
|
||||
return;
|
||||
}
|
||||
if self.is_review_mode {
|
||||
self.queued_user_messages.push_back(user_message);
|
||||
self.queued_user_messages.push_back(user_message.into());
|
||||
self.refresh_pending_input_preview();
|
||||
return;
|
||||
}
|
||||
@@ -5609,7 +5858,8 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// If idle and there are queued inputs, submit exactly one to start the next turn.
|
||||
// If idle and there are queued inputs, dispatch queued work in order until a turn starts or
|
||||
// a popup takes focus.
|
||||
pub(crate) fn maybe_send_next_queued_input(&mut self) {
|
||||
if self.suppress_queue_autosend {
|
||||
return;
|
||||
@@ -5617,10 +5867,21 @@ impl ChatWidget {
|
||||
if self.bottom_pane.is_task_running() {
|
||||
return;
|
||||
}
|
||||
if let Some(user_message) = self.queued_user_messages.pop_front() {
|
||||
self.submit_user_message(user_message);
|
||||
while !self.bottom_pane.is_task_running() {
|
||||
let Some(queued_input) = self.queued_user_messages.pop_front() else {
|
||||
break;
|
||||
};
|
||||
match queued_input {
|
||||
QueuedInput::UserMessage(user_message) => {
|
||||
self.submit_user_message(user_message);
|
||||
break;
|
||||
}
|
||||
QueuedInput::SlashCommand(command) => self.execute_queued_slash_command(command),
|
||||
}
|
||||
if !self.bottom_pane.no_modal_or_popup_active() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Update the list to reflect the remaining queued messages (if any).
|
||||
self.refresh_pending_input_preview();
|
||||
}
|
||||
|
||||
@@ -5629,7 +5890,7 @@ impl ChatWidget {
|
||||
let queued_messages: Vec<String> = self
|
||||
.queued_user_messages
|
||||
.iter()
|
||||
.map(|m| m.text.clone())
|
||||
.map(QueuedInput::preview_text)
|
||||
.collect();
|
||||
let pending_steers: Vec<String> = self
|
||||
.pending_steers
|
||||
@@ -8771,7 +9032,7 @@ impl ChatWidget {
|
||||
pub(crate) fn queued_user_message_texts(&self) -> Vec<String> {
|
||||
self.queued_user_messages
|
||||
.iter()
|
||||
.map(|message| message.text.clone())
|
||||
.map(QueuedInput::preview_text)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob
|
||||
---
|
||||
■ '/model' is disabled while a task is in progress.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
---
|
||||
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
|
||||
@@ -1235,26 +1235,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
|
||||
@@ -1318,13 +1324,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 {
|
||||
@@ -3696,9 +3705,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.
|
||||
@@ -3712,7 +3721,7 @@ async fn alt_up_edits_most_recent_queued_message() {
|
||||
// And the queue should now contain only the remaining (older) item.
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
chat.queued_user_messages.front().unwrap().preview_text(),
|
||||
"first queued"
|
||||
);
|
||||
}
|
||||
@@ -3730,9 +3739,9 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal(
|
||||
|
||||
// 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.
|
||||
@@ -3746,7 +3755,7 @@ async fn assert_shift_left_edits_most_recent_queued_message_for_terminal(
|
||||
// And the queue should now contain only the remaining (older) item.
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
chat.queued_user_messages.front().unwrap().preview_text(),
|
||||
"first queued"
|
||||
);
|
||||
}
|
||||
@@ -3815,7 +3824,7 @@ async fn enqueueing_history_prompt_multiple_times_is_stable() {
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 3);
|
||||
for message in chat.queued_user_messages.iter() {
|
||||
assert_eq!(message.text, "repeat me");
|
||||
assert_eq!(message.preview_text(), "repeat me");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3838,7 +3847,7 @@ async fn streaming_final_answer_ctrl_c_interrupt_preserves_background_shells() {
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
chat.queued_user_messages.front().unwrap().preview_text(),
|
||||
"queued submission"
|
||||
);
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
@@ -4015,7 +4024,7 @@ async fn steer_enter_queues_while_plan_stream_is_active() {
|
||||
assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan);
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
chat.queued_user_messages.front().unwrap().preview_text(),
|
||||
"queued submission"
|
||||
);
|
||||
assert!(chat.pending_steers.is_empty());
|
||||
@@ -4508,7 +4517,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());
|
||||
@@ -4533,7 +4542,7 @@ async fn esc_interrupt_sends_all_pending_steers_immediately_and_keeps_existing_d
|
||||
assert_eq!(chat.bottom_pane.composer_text(), "still editing");
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
chat.queued_user_messages.front().unwrap().preview_text(),
|
||||
"queued draft"
|
||||
);
|
||||
|
||||
@@ -4630,7 +4639,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) {
|
||||
@@ -4672,7 +4681,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) {
|
||||
@@ -6054,24 +6063,19 @@ async fn slash_clear_requests_ui_clear_when_idle() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slash_clear_is_disabled_while_task_running() {
|
||||
async fn slash_clear_queues_while_task_running() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.on_task_started();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Clear);
|
||||
|
||||
let event = rx.try_recv().expect("expected disabled command error");
|
||||
match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let rendered = lines_to_single_string(&cell.display_lines(80));
|
||||
assert!(
|
||||
rendered.contains("'/clear' is disabled while a task is in progress."),
|
||||
"expected /clear task-running error, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected InsertHistoryCell error, got {other:?}"),
|
||||
}
|
||||
assert!(rx.try_recv().is_err(), "expected no follow-up events");
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["/clear".to_string()]);
|
||||
assert!(rx.try_recv().is_err(), "expected no immediate app events");
|
||||
|
||||
chat.on_task_complete(None, false);
|
||||
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -8113,22 +8117,26 @@ async fn user_shell_command_renders_output_not_exploring() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_slash_command_while_task_running_snapshot() {
|
||||
// Build a chat widget and simulate an active task
|
||||
async fn queued_slash_command_while_task_running_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.on_task_started();
|
||||
|
||||
// Dispatch a command that is unavailable while a task runs (e.g., /model)
|
||||
chat.dispatch_command(SlashCommand::Model);
|
||||
|
||||
// Drain history and snapshot the rendered error line(s)
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
!cells.is_empty(),
|
||||
"expected an error message history cell to be emitted",
|
||||
);
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert_snapshot!(blob);
|
||||
assert_eq!(chat.queued_user_message_texts(), vec!["/model".to_string()]);
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
let width: u16 = 80;
|
||||
let height: u16 = 18;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
let desired_height = chat.desired_height(width).min(height);
|
||||
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
|
||||
term.draw(|f| {
|
||||
chat.render(f.area(), f.buffer_mut());
|
||||
})
|
||||
.unwrap();
|
||||
assert_snapshot!(term.backend().vt100().screen().contents());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -8999,9 +9007,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).
|
||||
@@ -9039,9 +9047,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 {
|
||||
@@ -11010,7 +11018,7 @@ async fn enter_queues_user_messages_while_review_is_running() {
|
||||
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
assert_eq!(
|
||||
chat.queued_user_messages.front().unwrap().text,
|
||||
chat.queued_user_messages.front().unwrap().preview_text(),
|
||||
"Queued while /review is running."
|
||||
);
|
||||
assert!(chat.pending_steers.is_empty());
|
||||
@@ -11018,6 +11026,68 @@ async fn enter_queues_user_messages_while_review_is_running() {
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_slash_command_queues_while_task_running_and_opens_popup_after_turn_complete() {
|
||||
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());
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
|
||||
chat.on_task_complete(None, false);
|
||||
|
||||
assert!(chat.queued_user_messages.is_empty());
|
||||
assert!(chat.has_active_view(), "expected /review popup to open");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_slash_command_with_args_queues_while_task_running_and_submits_after_turn_complete()
|
||||
{
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
chat.on_task_started();
|
||||
chat.bottom_pane.set_composer_text(
|
||||
"/review audit dependency changes".to_string(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(
|
||||
chat.queued_user_message_texts(),
|
||||
vec!["/review audit dependency changes".to_string()]
|
||||
);
|
||||
assert_no_submit_op(&mut op_rx);
|
||||
|
||||
chat.on_task_complete(None, false);
|
||||
|
||||
loop {
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Review { review_request }) => {
|
||||
assert_eq!(
|
||||
review_request,
|
||||
ReviewRequest {
|
||||
target: ReviewTarget::Custom {
|
||||
instructions: "audit dependency changes".to_string(),
|
||||
},
|
||||
user_facing_hint: None,
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => panic!("expected queued /review op"),
|
||||
Err(TryRecvError::Disconnected) => panic!("expected queued /review 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