mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
Compare commits
10 Commits
codex-cli-
...
daniel/rev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc8ce23ac | ||
|
|
5ca8c1de2d | ||
|
|
85dd6a849e | ||
|
|
b99c7dbee7 | ||
|
|
36a70eb84e | ||
|
|
3541d25341 | ||
|
|
279819e016 | ||
|
|
d045974fab | ||
|
|
2521f9b981 | ||
|
|
2405772af3 |
@@ -3293,7 +3293,7 @@ async fn exit_review_mode(
|
|||||||
<results>
|
<results>
|
||||||
{findings_str}
|
{findings_str}
|
||||||
</results>
|
</results>
|
||||||
</user_tool>
|
</user_action>
|
||||||
"#));
|
"#));
|
||||||
} else {
|
} else {
|
||||||
user_message.push_str(r#"<user_action>
|
user_message.push_str(r#"<user_action>
|
||||||
@@ -3302,7 +3302,7 @@ async fn exit_review_mode(
|
|||||||
<results>
|
<results>
|
||||||
None.
|
None.
|
||||||
</results>
|
</results>
|
||||||
</user_tool>
|
</user_action>
|
||||||
"#);
|
"#);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ pub fn format_review_findings_block(
|
|||||||
selection: Option<&[bool]>,
|
selection: Option<&[bool]>,
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut lines: Vec<String> = Vec::new();
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
lines.push(String::new());
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
let header = if findings.len() > 1 {
|
if findings.len() > 1 {
|
||||||
"Full review comments:"
|
lines.push("Full review comments:".to_string());
|
||||||
} else {
|
} else {
|
||||||
"Review comment:"
|
lines.push("Review comment:".to_string());
|
||||||
};
|
}
|
||||||
lines.push(header.to_string());
|
|
||||||
|
|
||||||
for (idx, item) in findings.iter().enumerate() {
|
for (idx, item) in findings.iter().enumerate() {
|
||||||
lines.push(String::new());
|
lines.push(String::new());
|
||||||
|
|||||||
@@ -558,6 +558,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
|||||||
TurnAbortReason::Replaced => {
|
TurnAbortReason::Replaced => {
|
||||||
ts_println!(self, "task aborted: replaced by a new task");
|
ts_println!(self, "task aborted: replaced by a new task");
|
||||||
}
|
}
|
||||||
|
TurnAbortReason::ReviewEnded => {
|
||||||
|
ts_println!(self, "task aborted: review ended");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||||
EventMsg::ConversationPath(_) => {}
|
EventMsg::ConversationPath(_) => {}
|
||||||
|
|||||||
@@ -1240,6 +1240,7 @@ pub struct TurnAbortedEvent {
|
|||||||
pub enum TurnAbortReason {
|
pub enum TurnAbortReason {
|
||||||
Interrupted,
|
Interrupted,
|
||||||
Replaced,
|
Replaced,
|
||||||
|
ReviewEnded,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -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_core::protocol::TokenUsageInfo;
|
||||||
use codex_protocol::num_format::format_si_suffix;
|
use codex_protocol::num_format::format_si_suffix;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
@@ -263,6 +266,19 @@ impl ChatComposer {
|
|||||||
self.sync_file_search_popup();
|
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.
|
/// Get the current composer text.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn current_text(&self) -> String {
|
pub(crate) fn current_text(&self) -> String {
|
||||||
@@ -1147,16 +1163,27 @@ impl ChatComposer {
|
|||||||
fn sync_command_popup(&mut self) {
|
fn sync_command_popup(&mut self) {
|
||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
let input_starts_with_slash = first_line.starts_with('/');
|
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 {
|
match &mut self.active_popup {
|
||||||
ActivePopup::Command(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());
|
popup.on_composer_text_change(first_line.to_string());
|
||||||
} else {
|
} else {
|
||||||
self.active_popup = ActivePopup::None;
|
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());
|
let mut command_popup = CommandPopup::new(self.custom_prompts.clone());
|
||||||
command_popup.on_composer_text_change(first_line.to_string());
|
command_popup.on_composer_text_change(first_line.to_string());
|
||||||
self.active_popup = ActivePopup::Command(command_popup);
|
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::AttachedImage;
|
||||||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||||
use crate::bottom_pane::textarea::TextArea;
|
use crate::bottom_pane::textarea::TextArea;
|
||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use crossterm::event::KeyModifiers;
|
||||||
use tokio::sync::mpsc::unbounded_channel;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1893,6 +1923,30 @@ mod tests {
|
|||||||
assert_eq!(composer.textarea.cursor(), composer.textarea.text().len());
|
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]
|
#[test]
|
||||||
fn slash_mention_dispatches_command_and_inserts_at() {
|
fn slash_mention_dispatches_command_and_inserts_at() {
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
|
|||||||
@@ -243,6 +243,12 @@ impl BottomPane {
|
|||||||
self.request_redraw();
|
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).
|
/// Get the current composer text (for tests and programmatic checks).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn composer_text(&self) -> String {
|
pub(crate) fn composer_text(&self) -> String {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use codex_core::protocol::EventMsg;
|
|||||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||||
use codex_core::protocol::ExecCommandBeginEvent;
|
use codex_core::protocol::ExecCommandBeginEvent;
|
||||||
use codex_core::protocol::ExecCommandEndEvent;
|
use codex_core::protocol::ExecCommandEndEvent;
|
||||||
|
use codex_core::protocol::ExitedReviewModeEvent;
|
||||||
use codex_core::protocol::InputItem;
|
use codex_core::protocol::InputItem;
|
||||||
use codex_core::protocol::InputMessageKind;
|
use codex_core::protocol::InputMessageKind;
|
||||||
use codex_core::protocol::ListCustomPromptsResponseEvent;
|
use codex_core::protocol::ListCustomPromptsResponseEvent;
|
||||||
@@ -27,6 +28,7 @@ use codex_core::protocol::McpToolCallBeginEvent;
|
|||||||
use codex_core::protocol::McpToolCallEndEvent;
|
use codex_core::protocol::McpToolCallEndEvent;
|
||||||
use codex_core::protocol::Op;
|
use codex_core::protocol::Op;
|
||||||
use codex_core::protocol::PatchApplyBeginEvent;
|
use codex_core::protocol::PatchApplyBeginEvent;
|
||||||
|
use codex_core::protocol::ReviewRequest;
|
||||||
use codex_core::protocol::StreamErrorEvent;
|
use codex_core::protocol::StreamErrorEvent;
|
||||||
use codex_core::protocol::TaskCompleteEvent;
|
use codex_core::protocol::TaskCompleteEvent;
|
||||||
use codex_core::protocol::TokenUsage;
|
use codex_core::protocol::TokenUsage;
|
||||||
@@ -36,6 +38,7 @@ use codex_core::protocol::TurnDiffEvent;
|
|||||||
use codex_core::protocol::UserMessageEvent;
|
use codex_core::protocol::UserMessageEvent;
|
||||||
use codex_core::protocol::WebSearchBeginEvent;
|
use codex_core::protocol::WebSearchBeginEvent;
|
||||||
use codex_core::protocol::WebSearchEndEvent;
|
use codex_core::protocol::WebSearchEndEvent;
|
||||||
|
use codex_protocol::mcp_protocol::ConversationId;
|
||||||
use codex_protocol::parse_command::ParsedCommand;
|
use codex_protocol::parse_command::ParsedCommand;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
@@ -63,11 +66,17 @@ use crate::clipboard_paste::paste_image_to_temp_png;
|
|||||||
use crate::diff_render::display_path_for;
|
use crate::diff_render::display_path_for;
|
||||||
use crate::get_git_diff::get_git_diff;
|
use crate::get_git_diff::get_git_diff;
|
||||||
use crate::history_cell;
|
use crate::history_cell;
|
||||||
|
use crate::history_cell::AgentMessageCell;
|
||||||
use crate::history_cell::CommandOutput;
|
use crate::history_cell::CommandOutput;
|
||||||
use crate::history_cell::ExecCell;
|
use crate::history_cell::ExecCell;
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
use crate::history_cell::PatchEventType;
|
use crate::history_cell::PatchEventType;
|
||||||
|
use crate::markdown::append_markdown;
|
||||||
use crate::slash_command::SlashCommand;
|
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::text_formatting::truncate_text;
|
||||||
use crate::tui::FrameRequester;
|
use crate::tui::FrameRequester;
|
||||||
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
||||||
@@ -91,7 +100,6 @@ use codex_core::protocol::AskForApproval;
|
|||||||
use codex_core::protocol::SandboxPolicy;
|
use codex_core::protocol::SandboxPolicy;
|
||||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
use codex_protocol::mcp_protocol::ConversationId;
|
|
||||||
|
|
||||||
// Track information about an in-flight exec command.
|
// Track information about an in-flight exec command.
|
||||||
struct RunningCommand {
|
struct RunningCommand {
|
||||||
@@ -141,6 +149,8 @@ pub(crate) struct ChatWidget {
|
|||||||
queued_user_messages: VecDeque<UserMessage>,
|
queued_user_messages: VecDeque<UserMessage>,
|
||||||
// Pending notification to show when unfocused on next Draw
|
// Pending notification to show when unfocused on next Draw
|
||||||
pending_notification: Option<Notification>,
|
pending_notification: Option<Notification>,
|
||||||
|
// Simple review mode flag; used to adjust layout and banners.
|
||||||
|
is_review_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UserMessage {
|
struct UserMessage {
|
||||||
@@ -277,13 +287,10 @@ impl ChatWidget {
|
|||||||
self.bottom_pane.set_token_usage(info.clone());
|
self.bottom_pane.set_token_usage(info.clone());
|
||||||
self.token_info = info;
|
self.token_info = info;
|
||||||
}
|
}
|
||||||
/// Finalize any active exec as failed, push an error message into history,
|
/// Finalize any active exec as failed and stop/clear running UI state.
|
||||||
/// and stop/clear running UI state.
|
fn finalize_turn(&mut self) {
|
||||||
fn finalize_turn_with_error_message(&mut self, message: String) {
|
|
||||||
// Ensure any spinner is replaced by a red ✗ and flushed into history.
|
// Ensure any spinner is replaced by a red ✗ and flushed into history.
|
||||||
self.finalize_active_exec_cell_as_failed();
|
self.finalize_active_exec_cell_as_failed();
|
||||||
// Emit the provided error message/history cell.
|
|
||||||
self.add_to_history(history_cell::new_error_event(message));
|
|
||||||
// Reset running state and clear streaming buffers.
|
// Reset running state and clear streaming buffers.
|
||||||
self.bottom_pane.set_task_running(false);
|
self.bottom_pane.set_task_running(false);
|
||||||
self.running_commands.clear();
|
self.running_commands.clear();
|
||||||
@@ -291,7 +298,8 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn on_error(&mut self, message: String) {
|
fn on_error(&mut self, message: String) {
|
||||||
self.finalize_turn_with_error_message(message);
|
self.finalize_turn();
|
||||||
|
self.add_to_history(history_cell::new_error_event(message));
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
|
|
||||||
// After an error ends the turn, try sending the next queued input.
|
// After an error ends the turn, try sending the next queued input.
|
||||||
@@ -301,11 +309,15 @@ impl ChatWidget {
|
|||||||
/// Handle a turn aborted due to user interrupt (Esc).
|
/// Handle a turn aborted due to user interrupt (Esc).
|
||||||
/// When there are queued user messages, restore them into the composer
|
/// When there are queued user messages, restore them into the composer
|
||||||
/// separated by newlines rather than auto‑submitting the next one.
|
/// separated by newlines rather than auto‑submitting the next one.
|
||||||
fn on_interrupted_turn(&mut self) {
|
fn on_interrupted_turn(&mut self, reason: TurnAbortReason) {
|
||||||
// Finalize, log a gentle prompt, and clear running state.
|
// Finalize, log a gentle prompt, and clear running state.
|
||||||
self.finalize_turn_with_error_message(
|
self.finalize_turn();
|
||||||
"Conversation interrupted - tell the model what to do differently".to_owned(),
|
|
||||||
);
|
if reason != TurnAbortReason::ReviewEnded {
|
||||||
|
self.add_to_history(history_cell::new_error_event(
|
||||||
|
"Conversation interrupted - tell the model what to do differently".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// If any messages were queued during the task, restore them into the composer.
|
// If any messages were queued during the task, restore them into the composer.
|
||||||
if !self.queued_user_messages.is_empty() {
|
if !self.queued_user_messages.is_empty() {
|
||||||
@@ -700,6 +712,7 @@ impl ChatWidget {
|
|||||||
show_welcome_banner: true,
|
show_welcome_banner: true,
|
||||||
suppress_session_configured_redraw: false,
|
suppress_session_configured_redraw: false,
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
|
is_review_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,6 +769,7 @@ impl ChatWidget {
|
|||||||
show_welcome_banner: true,
|
show_welcome_banner: true,
|
||||||
suppress_session_configured_redraw: true,
|
suppress_session_configured_redraw: true,
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
|
is_review_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,7 +826,51 @@ impl ChatWidget {
|
|||||||
_ => {
|
_ => {
|
||||||
match self.bottom_pane.handle_key_event(key_event) {
|
match self.bottom_pane.handle_key_event(key_event) {
|
||||||
InputResult::Submitted(text) => {
|
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 {
|
let user_message = UserMessage {
|
||||||
text,
|
text,
|
||||||
image_paths: self.bottom_pane.take_recent_submission_images(),
|
image_paths: self.bottom_pane.take_recent_submission_images(),
|
||||||
@@ -870,6 +928,14 @@ impl ChatWidget {
|
|||||||
self.clear_token_usage();
|
self.clear_token_usage();
|
||||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||||
}
|
}
|
||||||
|
SlashCommand::Review => {
|
||||||
|
// 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 => {
|
SlashCommand::Model => {
|
||||||
self.open_model_popup();
|
self.open_model_popup();
|
||||||
}
|
}
|
||||||
@@ -1087,11 +1153,14 @@ impl ChatWidget {
|
|||||||
EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
|
EventMsg::Error(ErrorEvent { message }) => self.on_error(message),
|
||||||
EventMsg::TurnAborted(ev) => match ev.reason {
|
EventMsg::TurnAborted(ev) => match ev.reason {
|
||||||
TurnAbortReason::Interrupted => {
|
TurnAbortReason::Interrupted => {
|
||||||
self.on_interrupted_turn();
|
self.on_interrupted_turn(ev.reason);
|
||||||
}
|
}
|
||||||
TurnAbortReason::Replaced => {
|
TurnAbortReason::Replaced => {
|
||||||
self.on_error("Turn aborted: replaced by a new task".to_owned())
|
self.on_error("Turn aborted: replaced by a new task".to_owned())
|
||||||
}
|
}
|
||||||
|
TurnAbortReason::ReviewEnded => {
|
||||||
|
self.on_interrupted_turn(ev.reason);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
|
EventMsg::PlanUpdate(update) => self.on_plan_update(update),
|
||||||
EventMsg::ExecApprovalRequest(ev) => {
|
EventMsg::ExecApprovalRequest(ev) => {
|
||||||
@@ -1128,11 +1197,62 @@ impl ChatWidget {
|
|||||||
self.app_event_tx
|
self.app_event_tx
|
||||||
.send(crate::app_event::AppEvent::ConversationHistory(ev));
|
.send(crate::app_event::AppEvent::ConversationHistory(ev));
|
||||||
}
|
}
|
||||||
EventMsg::EnteredReviewMode(_) => {}
|
EventMsg::EnteredReviewMode(review_request) => {
|
||||||
EventMsg::ExitedReviewMode(_) => {}
|
self.on_entered_review_mode(review_request)
|
||||||
|
}
|
||||||
|
EventMsg::ExitedReviewMode(review) => self.on_exited_review_mode(review),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_entered_review_mode(&mut self, review: ReviewRequest) {
|
||||||
|
// Enter review mode and emit a concise banner
|
||||||
|
self.is_review_mode = true;
|
||||||
|
let banner = format!(">> Code review started: {} <<", review.user_facing_hint);
|
||||||
|
self.add_to_history(history_cell::new_review_status_line(banner));
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_exited_review_mode(&mut self, review: ExitedReviewModeEvent) {
|
||||||
|
// Leave review mode; if output is present, flush pending stream + show results.
|
||||||
|
if let Some(output) = review.review_output {
|
||||||
|
self.flush_answer_stream_with_separator();
|
||||||
|
self.flush_interrupt_queue();
|
||||||
|
self.flush_active_exec_cell();
|
||||||
|
|
||||||
|
if output.findings.is_empty() {
|
||||||
|
let explanation = output.overall_explanation.trim().to_string();
|
||||||
|
if explanation.is_empty() {
|
||||||
|
tracing::error!("Reviewer failed to output a response.");
|
||||||
|
self.add_to_history(history_cell::new_error_event(
|
||||||
|
"Reviewer failed to output a response.".to_owned(),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Show explanation when there are no structured findings.
|
||||||
|
let mut rendered: Vec<ratatui::text::Line<'static>> = vec!["".into()];
|
||||||
|
append_markdown(&explanation, &mut rendered, &self.config);
|
||||||
|
let body_cell = AgentMessageCell::new(rendered, false);
|
||||||
|
self.app_event_tx
|
||||||
|
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let message_text =
|
||||||
|
codex_core::review_format::format_review_findings_block(&output.findings, None);
|
||||||
|
let mut message_lines: Vec<ratatui::text::Line<'static>> = Vec::new();
|
||||||
|
append_markdown(&message_text, &mut message_lines, &self.config);
|
||||||
|
let body_cell = AgentMessageCell::new(message_lines, true);
|
||||||
|
self.app_event_tx
|
||||||
|
.send(AppEvent::InsertHistoryCell(Box::new(body_cell)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.is_review_mode = false;
|
||||||
|
// Append a finishing banner at the end of this turn.
|
||||||
|
self.add_to_history(history_cell::new_review_status_line(
|
||||||
|
"<< Code review finished >>".to_string(),
|
||||||
|
));
|
||||||
|
self.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
fn on_user_message_event(&mut self, event: UserMessageEvent) {
|
fn on_user_message_event(&mut self, event: UserMessageEvent) {
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Some(InputMessageKind::EnvironmentContext)
|
Some(InputMessageKind::EnvironmentContext)
|
||||||
@@ -1181,6 +1301,21 @@ impl ChatWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(user_message) = self.queued_user_messages.pop_front() {
|
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);
|
self.submit_user_message(user_message);
|
||||||
}
|
}
|
||||||
// Update the list to reflect the remaining queued messages (if any).
|
// Update the list to reflect the remaining queued messages (if any).
|
||||||
|
|||||||
@@ -19,10 +19,17 @@ use codex_core::protocol::EventMsg;
|
|||||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||||
use codex_core::protocol::ExecCommandBeginEvent;
|
use codex_core::protocol::ExecCommandBeginEvent;
|
||||||
use codex_core::protocol::ExecCommandEndEvent;
|
use codex_core::protocol::ExecCommandEndEvent;
|
||||||
|
use codex_core::protocol::ExitedReviewModeEvent;
|
||||||
use codex_core::protocol::FileChange;
|
use codex_core::protocol::FileChange;
|
||||||
use codex_core::protocol::InputMessageKind;
|
use codex_core::protocol::InputMessageKind;
|
||||||
|
use codex_core::protocol::Op;
|
||||||
use codex_core::protocol::PatchApplyBeginEvent;
|
use codex_core::protocol::PatchApplyBeginEvent;
|
||||||
use codex_core::protocol::PatchApplyEndEvent;
|
use codex_core::protocol::PatchApplyEndEvent;
|
||||||
|
use codex_core::protocol::ReviewCodeLocation;
|
||||||
|
use codex_core::protocol::ReviewFinding;
|
||||||
|
use codex_core::protocol::ReviewLineRange;
|
||||||
|
use codex_core::protocol::ReviewOutputEvent;
|
||||||
|
use codex_core::protocol::ReviewRequest;
|
||||||
use codex_core::protocol::StreamErrorEvent;
|
use codex_core::protocol::StreamErrorEvent;
|
||||||
use codex_core::protocol::TaskCompleteEvent;
|
use codex_core::protocol::TaskCompleteEvent;
|
||||||
use codex_core::protocol::TaskStartedEvent;
|
use codex_core::protocol::TaskStartedEvent;
|
||||||
@@ -184,6 +191,155 @@ 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() {
|
||||||
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||||
|
|
||||||
|
chat.handle_codex_event(Event {
|
||||||
|
id: "review-start".into(),
|
||||||
|
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||||
|
prompt: "Review the latest changes".to_string(),
|
||||||
|
user_facing_hint: "feature branch".to_string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let cells = drain_insert_history(&mut rx);
|
||||||
|
let banner = lines_to_single_string(cells.last().expect("review banner"));
|
||||||
|
assert_eq!(banner, ">> Code review started: feature branch <<\n");
|
||||||
|
assert!(chat.is_review_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entering review mode renders the current changes banner when requested.
|
||||||
|
#[test]
|
||||||
|
fn entered_review_mode_defaults_to_current_changes_banner() {
|
||||||
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||||
|
|
||||||
|
chat.handle_codex_event(Event {
|
||||||
|
id: "review-start".into(),
|
||||||
|
msg: EventMsg::EnteredReviewMode(ReviewRequest {
|
||||||
|
prompt: "Review the current changes".to_string(),
|
||||||
|
user_facing_hint: "current changes".to_string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let cells = drain_insert_history(&mut rx);
|
||||||
|
let banner = lines_to_single_string(cells.last().expect("review banner"));
|
||||||
|
assert_eq!(banner, ">> Code review started: current changes <<\n");
|
||||||
|
assert!(chat.is_review_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completing review with findings shows the selection popup and finishes with
|
||||||
|
/// the closing banner while clearing review mode state.
|
||||||
|
#[test]
|
||||||
|
fn exited_review_mode_emits_results_and_finishes() {
|
||||||
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||||
|
|
||||||
|
let review = ReviewOutputEvent {
|
||||||
|
findings: vec![ReviewFinding {
|
||||||
|
title: "[P1] Fix bug".to_string(),
|
||||||
|
body: "Something went wrong".to_string(),
|
||||||
|
confidence_score: 0.9,
|
||||||
|
priority: 1,
|
||||||
|
code_location: ReviewCodeLocation {
|
||||||
|
absolute_file_path: PathBuf::from("src/lib.rs"),
|
||||||
|
line_range: ReviewLineRange { start: 10, end: 12 },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
overall_correctness: "needs work".to_string(),
|
||||||
|
overall_explanation: "Investigate the failure".to_string(),
|
||||||
|
overall_confidence_score: 0.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
chat.handle_codex_event(Event {
|
||||||
|
id: "review-end".into(),
|
||||||
|
msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent {
|
||||||
|
review_output: Some(review),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let cells = drain_insert_history(&mut rx);
|
||||||
|
let banner = lines_to_single_string(cells.last().expect("finished banner"));
|
||||||
|
assert_eq!(banner, "\n<< Code review finished >>\n");
|
||||||
|
assert!(!chat.is_review_mode);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
target_os = "macos",
|
target_os = "macos",
|
||||||
ignore = "system configuration APIs are blocked under macOS seatbelt"
|
ignore = "system configuration APIs are blocked under macOS seatbelt"
|
||||||
@@ -252,6 +408,7 @@ fn make_chatwidget_manual() -> (
|
|||||||
queued_user_messages: VecDeque::new(),
|
queued_user_messages: VecDeque::new(),
|
||||||
suppress_session_configured_redraw: false,
|
suppress_session_configured_redraw: false,
|
||||||
pending_notification: None,
|
pending_notification: None,
|
||||||
|
is_review_mode: false,
|
||||||
};
|
};
|
||||||
(widget, rx, op_rx)
|
(widget, rx, op_rx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,13 @@ impl HistoryCell for TranscriptOnlyHistoryCell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cyan history cell line showing the current review status.
|
||||||
|
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
|
||||||
|
PlainHistoryCell {
|
||||||
|
lines: vec![Line::from(message.cyan())],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct PatchHistoryCell {
|
pub(crate) struct PatchHistoryCell {
|
||||||
event_type: PatchEventType,
|
event_type: PatchEventType,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
use strum::IntoEnumIterator;
|
use strum::IntoEnumIterator;
|
||||||
use strum_macros::AsRefStr;
|
use strum_macros::AsRefStr;
|
||||||
use strum_macros::EnumIter;
|
use strum_macros::EnumIter;
|
||||||
use strum_macros::EnumString;
|
use strum_macros::EnumString;
|
||||||
use strum_macros::IntoStaticStr;
|
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.
|
/// Commands that can be invoked by starting a message with a leading slash.
|
||||||
#[derive(
|
#[derive(
|
||||||
Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr,
|
Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr,
|
||||||
@@ -14,6 +18,7 @@ pub enum SlashCommand {
|
|||||||
// more frequently used commands should be listed first.
|
// more frequently used commands should be listed first.
|
||||||
Model,
|
Model,
|
||||||
Approvals,
|
Approvals,
|
||||||
|
Review,
|
||||||
New,
|
New,
|
||||||
Init,
|
Init,
|
||||||
Compact,
|
Compact,
|
||||||
@@ -34,6 +39,7 @@ impl SlashCommand {
|
|||||||
SlashCommand::New => "start a new chat during a conversation",
|
SlashCommand::New => "start a new chat during a conversation",
|
||||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||||
|
SlashCommand::Review => "review my current changes and find issues",
|
||||||
SlashCommand::Quit => "exit Codex",
|
SlashCommand::Quit => "exit Codex",
|
||||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||||
SlashCommand::Mention => "mention a file",
|
SlashCommand::Mention => "mention a file",
|
||||||
@@ -61,6 +67,7 @@ impl SlashCommand {
|
|||||||
| SlashCommand::Compact
|
| SlashCommand::Compact
|
||||||
| SlashCommand::Model
|
| SlashCommand::Model
|
||||||
| SlashCommand::Approvals
|
| SlashCommand::Approvals
|
||||||
|
| SlashCommand::Review
|
||||||
| SlashCommand::Logout => false,
|
| SlashCommand::Logout => false,
|
||||||
SlashCommand::Diff
|
SlashCommand::Diff
|
||||||
| SlashCommand::Mention
|
| SlashCommand::Mention
|
||||||
@@ -78,3 +85,56 @@ impl SlashCommand {
|
|||||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
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