mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
/review compose mode
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
use crate::slash_command::SlashInputMode;
|
||||
use crate::slash_command::parse_slash_invocation;
|
||||
use crate::slash_command::slash_input_mode;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_protocol::num_format::format_si_suffix;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -263,6 +266,19 @@ impl ChatComposer {
|
||||
self.sync_file_search_popup();
|
||||
}
|
||||
|
||||
/// Move the cursor to the end of the current line/content and resync popups.
|
||||
pub(crate) fn move_cursor_to_end(&mut self) {
|
||||
let end = self.textarea.text().len();
|
||||
self.textarea.set_cursor(end);
|
||||
// Keep popup sync consistent with other cursor/text changes.
|
||||
self.sync_command_popup();
|
||||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||||
self.dismissed_file_popup_token = None;
|
||||
} else {
|
||||
self.sync_file_search_popup();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current composer text.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn current_text(&self) -> String {
|
||||
@@ -1147,16 +1163,27 @@ impl ChatComposer {
|
||||
fn sync_command_popup(&mut self) {
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
let input_starts_with_slash = first_line.starts_with('/');
|
||||
|
||||
// Suppress the slash popup when the input is an already-formed compose command
|
||||
// with additional text (e.g., "/review <prompt>").
|
||||
let mut suppress_for_compose = false;
|
||||
if let Some((cmd, remainder)) = parse_slash_invocation(first_line) {
|
||||
if let SlashInputMode::Compose { .. } = slash_input_mode(cmd) {
|
||||
if !remainder.is_empty() {
|
||||
suppress_for_compose = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
match &mut self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
if input_starts_with_slash {
|
||||
if input_starts_with_slash && !suppress_for_compose {
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
} else {
|
||||
self.active_popup = ActivePopup::None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if input_starts_with_slash {
|
||||
if input_starts_with_slash && !suppress_for_compose {
|
||||
let mut command_popup = CommandPopup::new(self.custom_prompts.clone());
|
||||
command_popup.on_composer_text_change(first_line.to_string());
|
||||
self.active_popup = ActivePopup::Command(command_popup);
|
||||
@@ -1380,6 +1407,9 @@ mod tests {
|
||||
use crate::bottom_pane::chat_composer::AttachedImage;
|
||||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use crate::bottom_pane::textarea::TextArea;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
@@ -1893,6 +1923,30 @@ mod tests {
|
||||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_review_suppresses_slash_popup_when_remainder_present() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
// Type '/rev' to show the slash popup filtered to review
|
||||
type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v']);
|
||||
assert!(composer.popup_active(), "popup should be active for '/rev'");
|
||||
|
||||
// Add remainder after the command; popup should be suppressed
|
||||
type_chars_humanlike(&mut composer, &['i', 'e', 'w', ' ', 'x']);
|
||||
assert!(
|
||||
!composer.popup_active(),
|
||||
"popup should be suppressed for '/review <text>'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_mention_dispatches_command_and_inserts_at() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -243,6 +243,12 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Move the composer cursor to the end of the current content.
|
||||
pub(crate) fn move_cursor_to_end(&mut self) {
|
||||
self.composer.move_cursor_to_end();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Get the current composer text (for tests and programmatic checks).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn composer_text(&self) -> String {
|
||||
|
||||
@@ -73,6 +73,10 @@ use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::SlashInputMode;
|
||||
use crate::slash_command::parse_slash_invocation;
|
||||
use crate::slash_command::slash_input_mode;
|
||||
use crate::slash_command::slash_submit_op;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
||||
@@ -822,7 +826,51 @@ impl ChatWidget {
|
||||
_ => {
|
||||
match self.bottom_pane.handle_key_event(key_event) {
|
||||
InputResult::Submitted(text) => {
|
||||
// If a task is running, queue the user input to be sent after the turn completes.
|
||||
// Generic compose-style submission: interpret `/<cmd> ...` if cmd is compose.
|
||||
if let Some((cmd, remainder)) = parse_slash_invocation(&text) {
|
||||
if let SlashInputMode::Compose { default_prompt, .. } =
|
||||
slash_input_mode(cmd)
|
||||
{
|
||||
let prompt = if remainder.is_empty() {
|
||||
default_prompt.to_string()
|
||||
} else {
|
||||
remainder.to_string()
|
||||
};
|
||||
|
||||
// Mirror normal flow: persist and show the user's message text.
|
||||
self.add_to_history(history_cell::new_user_prompt(text.clone()));
|
||||
self.codex_op_tx
|
||||
.send(Op::AddToHistory { text: text.clone() })
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("failed to send AddHistory op: {e}");
|
||||
});
|
||||
|
||||
if self.bottom_pane.is_task_running() {
|
||||
// Queue raw text; dequeue path will re-interpret and submit.
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images(),
|
||||
};
|
||||
self.queued_user_messages.push_back(user_message);
|
||||
self.refresh_queued_user_messages();
|
||||
} else if let Some(op) = slash_submit_op(cmd, prompt) {
|
||||
self.submit_op(op);
|
||||
} else {
|
||||
// Fallback: treat as a normal message if no submit mapping exists.
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self
|
||||
.bottom_pane
|
||||
.take_recent_submission_images(),
|
||||
};
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Normal user input path
|
||||
let user_message = UserMessage {
|
||||
text,
|
||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||
@@ -881,13 +929,12 @@ impl ChatWidget {
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||
}
|
||||
SlashCommand::Review => {
|
||||
// Simplified flow: directly send a review op for current changes.
|
||||
self.submit_op(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: "review current changes".to_string(),
|
||||
user_facing_hint: "current changes".to_string(),
|
||||
},
|
||||
});
|
||||
// Prefill the composer with a review command allowing the user to edit.
|
||||
self.bottom_pane
|
||||
.set_composer_text("/review Review my current changes.".to_string());
|
||||
// Move cursor to the end so typing continues after the prefill.
|
||||
self.bottom_pane.move_cursor_to_end();
|
||||
self.request_redraw();
|
||||
}
|
||||
SlashCommand::Model => {
|
||||
self.open_model_popup();
|
||||
@@ -1254,6 +1301,21 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
if let Some(user_message) = self.queued_user_messages.pop_front() {
|
||||
// Intercept queued compose-style commands and convert to their Ops.
|
||||
if let Some((cmd, remainder)) = parse_slash_invocation(&user_message.text) {
|
||||
if let SlashInputMode::Compose { default_prompt, .. } = slash_input_mode(cmd) {
|
||||
let prompt = if remainder.is_empty() {
|
||||
default_prompt.to_string()
|
||||
} else {
|
||||
remainder.to_string()
|
||||
};
|
||||
if let Some(op) = slash_submit_op(cmd, prompt) {
|
||||
self.submit_op(op);
|
||||
self.refresh_queued_user_messages();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
// Update the list to reflect the remaining queued messages (if any).
|
||||
|
||||
@@ -191,6 +191,82 @@ fn resumed_initial_messages_render_history() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Compose-mode: selecting /review pre-fills the composer and moves cursor to end.
|
||||
#[test]
|
||||
fn compose_review_prefill_and_cursor() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (mut chat, _rx, _ops) = make_chatwidget_manual();
|
||||
chat.dispatch_command(SlashCommand::Review);
|
||||
|
||||
// Prefill text present
|
||||
assert_eq!(
|
||||
chat.bottom_pane.composer_text(),
|
||||
"/review Review my current changes."
|
||||
);
|
||||
|
||||
// Typing continues at the end (smoke check by submitting)
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
/// Compose-mode: pressing Enter sends a mapped Review op with the default prompt.
|
||||
#[test]
|
||||
fn compose_review_enter_sends_review_op() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual();
|
||||
chat.dispatch_command(SlashCommand::Review);
|
||||
// Immediately submit without editing; should use default prompt.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Collect ops until we see a Review
|
||||
let mut saw_review = false;
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
if let Op::Review { review_request } = op {
|
||||
assert_eq!(review_request.prompt, "Review my current changes.");
|
||||
saw_review = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_review, "expected a Review op to be submitted");
|
||||
}
|
||||
|
||||
/// Compose-mode queueing: Enter while task running queues the user text; when idle, it submits Review.
|
||||
#[test]
|
||||
fn compose_review_queue_then_submit_when_idle() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual();
|
||||
// Prefill while idle
|
||||
chat.dispatch_command(SlashCommand::Review);
|
||||
// Now a task begins; Enter should queue the message instead of sending.
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Should be queued, not sent yet
|
||||
assert_eq!(chat.queued_user_messages.len(), 1);
|
||||
|
||||
// Become idle and trigger dequeue
|
||||
chat.bottom_pane.set_task_running(false);
|
||||
chat.maybe_send_next_queued_input();
|
||||
|
||||
let mut saw_review = false;
|
||||
while let Ok(op) = op_rx.try_recv() {
|
||||
if let Op::Review { review_request } = op {
|
||||
assert_eq!(review_request.prompt, "Review my current changes.");
|
||||
saw_review = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_review, "expected a Review op after becoming idle");
|
||||
}
|
||||
|
||||
/// Entering review mode uses the hint provided by the review request.
|
||||
#[test]
|
||||
fn entered_review_mode_uses_request_hint() {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use std::str::FromStr;
|
||||
use strum::IntoEnumIterator;
|
||||
use strum_macros::AsRefStr;
|
||||
use strum_macros::EnumIter;
|
||||
use strum_macros::EnumString;
|
||||
use strum_macros::IntoStaticStr;
|
||||
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewRequest;
|
||||
|
||||
/// Commands that can be invoked by starting a message with a leading slash.
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr,
|
||||
@@ -81,3 +85,56 @@ impl SlashCommand {
|
||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
||||
}
|
||||
|
||||
/// Input mode for a slash command.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SlashInputMode {
|
||||
/// Execute immediately (handled via popup selection or dispatch_command).
|
||||
Immediate,
|
||||
/// Prefill composer with `/<cmd> <default_prompt>`; on Enter, submit a specific Op.
|
||||
Compose { default_prompt: &'static str },
|
||||
}
|
||||
|
||||
/// Describe how a built-in command is edited/submitted.
|
||||
pub fn slash_input_mode(cmd: SlashCommand) -> SlashInputMode {
|
||||
match cmd {
|
||||
SlashCommand::Review => SlashInputMode::Compose {
|
||||
default_prompt: "Review my current changes.",
|
||||
},
|
||||
_ => SlashInputMode::Immediate,
|
||||
}
|
||||
}
|
||||
|
||||
/// If `text` begins with a built-in slash command, return the command and the
|
||||
/// remainder (everything after the command token, across newlines). Callers that
|
||||
/// only want to consider the first line can pass just that slice.
|
||||
pub fn parse_slash_invocation(text: &str) -> Option<(SlashCommand, &str)> {
|
||||
// Must start with a slash.
|
||||
let after_slash = text.strip_prefix('/')?;
|
||||
// Allow optional whitespace after the slash before the command token.
|
||||
let token_start = after_slash.trim_start();
|
||||
let mut parts = token_start.splitn(2, char::is_whitespace);
|
||||
let cmd_token = parts.next()?;
|
||||
if cmd_token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cmd = SlashCommand::from_str(cmd_token).ok()?;
|
||||
// Preserve the rest of the original input (including newlines),
|
||||
// trimming only leading/trailing whitespace around it.
|
||||
let remainder = parts.next().map(str::trim).unwrap_or("");
|
||||
Some((cmd, remainder))
|
||||
}
|
||||
|
||||
/// Map a compose-style command + remainder into an Op for Codex.
|
||||
/// Returns None for commands that don't have a direct Op mapping.
|
||||
pub fn slash_submit_op(cmd: SlashCommand, remainder: String) -> Option<Op> {
|
||||
match cmd {
|
||||
SlashCommand::Review => Some(Op::Review {
|
||||
review_request: ReviewRequest {
|
||||
prompt: remainder.clone(),
|
||||
user_facing_hint: remainder,
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user