feat: Add more /review options (#3961)
Adds the following options: 1. Review current changes 2. Review a specific commit 3. Review against a base branch (PR style) 4. Custom instructions <img width="487" height="330" alt="Screenshot 2025-09-20 at 2 11 36 PM" src="https://github.com/user-attachments/assets/edb0aaa5-5747-47fa-881f-cc4c4f7fe8bc" /> --- \+ Adds the following UI helpers: 1. Makes list selection searchable 2. Adds navigation to the bottom pane, so you could add a stack of popups 3. Basic custom prompt view
This commit is contained in:
@@ -801,6 +801,135 @@ fn exec_history_cell_shows_working_then_failed() {
|
||||
assert!(blob.to_lowercase().contains("bloop"), "expected error text");
|
||||
}
|
||||
|
||||
/// Selecting the custom prompt option from the review popup sends
|
||||
/// OpenReviewCustomPrompt to the app event channel.
|
||||
#[test]
|
||||
fn review_popup_custom_prompt_action_sends_event() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Open the preset selection popup
|
||||
chat.open_review_popup();
|
||||
|
||||
// Move selection down to the fourth item: "Custom review instructions"
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
// Activate
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Drain events and ensure we saw the OpenReviewCustomPrompt request
|
||||
let mut found = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::OpenReviewCustomPrompt = ev {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found, "expected OpenReviewCustomPrompt event to be sent");
|
||||
}
|
||||
|
||||
/// The commit picker shows only commit subjects (no timestamps).
|
||||
#[test]
|
||||
fn review_commit_picker_shows_subjects_without_timestamps() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Open the Review presets parent popup.
|
||||
chat.open_review_popup();
|
||||
|
||||
// Show commit picker with synthetic entries.
|
||||
let entries = vec![
|
||||
codex_core::git_info::CommitLogEntry {
|
||||
sha: "1111111deadbeef".to_string(),
|
||||
timestamp: 0,
|
||||
subject: "Add new feature X".to_string(),
|
||||
},
|
||||
codex_core::git_info::CommitLogEntry {
|
||||
sha: "2222222cafebabe".to_string(),
|
||||
timestamp: 0,
|
||||
subject: "Fix bug Y".to_string(),
|
||||
},
|
||||
];
|
||||
super::show_review_commit_picker_with_entries(&mut chat, entries);
|
||||
|
||||
// Render the bottom pane and inspect the lines for subjects and absence of time words.
|
||||
let width = 72;
|
||||
let height = chat.desired_height(width);
|
||||
let area = ratatui::layout::Rect::new(0, 0, width, height);
|
||||
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||
(&chat).render_ref(area, &mut buf);
|
||||
|
||||
let mut blob = String::new();
|
||||
for y in 0..area.height {
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
blob.push(' ');
|
||||
} else {
|
||||
blob.push_str(s);
|
||||
}
|
||||
}
|
||||
blob.push('\n');
|
||||
}
|
||||
|
||||
assert!(
|
||||
blob.contains("Add new feature X"),
|
||||
"expected subject in output"
|
||||
);
|
||||
assert!(blob.contains("Fix bug Y"), "expected subject in output");
|
||||
|
||||
// Ensure no relative-time phrasing is present.
|
||||
let lowered = blob.to_lowercase();
|
||||
assert!(
|
||||
!lowered.contains("ago")
|
||||
&& !lowered.contains(" second")
|
||||
&& !lowered.contains(" minute")
|
||||
&& !lowered.contains(" hour")
|
||||
&& !lowered.contains(" day"),
|
||||
"expected no relative time in commit picker output: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Submitting the custom prompt view sends Op::Review with the typed prompt
|
||||
/// and uses the same text for the user-facing hint.
|
||||
#[test]
|
||||
fn custom_prompt_submit_sends_review_op() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.show_review_custom_prompt();
|
||||
// Paste prompt text via ChatWidget handler, then submit
|
||||
chat.handle_paste(" please audit dependencies ".to_string());
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// Expect AppEvent::CodexOp(Op::Review { .. }) with trimmed prompt
|
||||
let evt = rx.try_recv().expect("expected one app event");
|
||||
match evt {
|
||||
AppEvent::CodexOp(Op::Review { review_request }) => {
|
||||
assert_eq!(
|
||||
review_request.prompt,
|
||||
"please audit dependencies".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
review_request.user_facing_hint,
|
||||
"please audit dependencies".to_string()
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected app event: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hitting Enter on an empty custom prompt view does not submit.
|
||||
#[test]
|
||||
fn custom_prompt_enter_empty_does_not_send() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.show_review_custom_prompt();
|
||||
// Enter without any text
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// No AppEvent::CodexOp should be sent
|
||||
assert!(rx.try_recv().is_err(), "no app event should be sent");
|
||||
}
|
||||
|
||||
// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗
|
||||
// marker (replacing the spinner) and flushes it into history.
|
||||
#[test]
|
||||
@@ -830,6 +959,110 @@ fn interrupt_exec_marks_failed_snapshot() {
|
||||
assert_snapshot!("interrupt_exec_marks_failed", exec_blob);
|
||||
}
|
||||
|
||||
/// Opening custom prompt from the review popup, pressing Esc returns to the
|
||||
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
|
||||
#[test]
|
||||
fn review_custom_prompt_escape_navigates_back_then_dismisses() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Open the Review presets parent popup.
|
||||
chat.open_review_popup();
|
||||
|
||||
// Open the custom prompt submenu (child view) directly.
|
||||
chat.show_review_custom_prompt();
|
||||
|
||||
// Verify child view is on top.
|
||||
let header = render_bottom_first_row(&chat, 60);
|
||||
assert!(
|
||||
header.contains("Custom review instructions"),
|
||||
"expected custom prompt view header: {header:?}"
|
||||
);
|
||||
|
||||
// Esc once: child view closes, parent (review presets) remains.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
// Process emitted app events to reopen the parent review popup.
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::OpenReviewPopup = ev {
|
||||
chat.open_review_popup();
|
||||
break;
|
||||
}
|
||||
}
|
||||
let header = render_bottom_first_row(&chat, 60);
|
||||
assert!(
|
||||
header.contains("Select a review preset"),
|
||||
"expected to return to parent review popup: {header:?}"
|
||||
);
|
||||
|
||||
// Esc again: parent closes; back to normal composer state.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.is_normal_backtrack_mode(),
|
||||
"expected to be back in normal composer mode"
|
||||
);
|
||||
}
|
||||
|
||||
/// Opening base-branch picker from the review popup, pressing Esc returns to the
|
||||
/// parent popup, pressing Esc again dismisses all panels (back to normal mode).
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn review_branch_picker_escape_navigates_back_then_dismisses() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Open the Review presets parent popup.
|
||||
chat.open_review_popup();
|
||||
|
||||
// Open the branch picker submenu (child view). Using a temp cwd with no git repo is fine.
|
||||
let cwd = std::env::temp_dir();
|
||||
chat.show_review_branch_picker(&cwd).await;
|
||||
|
||||
// Verify child view header.
|
||||
let header = render_bottom_first_row(&chat, 60);
|
||||
assert!(
|
||||
header.contains("Select a base branch"),
|
||||
"expected branch picker header: {header:?}"
|
||||
);
|
||||
|
||||
// Esc once: child view closes, parent remains.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
// Process emitted app events to reopen the parent review popup.
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::OpenReviewPopup = ev {
|
||||
chat.open_review_popup();
|
||||
break;
|
||||
}
|
||||
}
|
||||
let header = render_bottom_first_row(&chat, 60);
|
||||
assert!(
|
||||
header.contains("Select a review preset"),
|
||||
"expected to return to parent review popup: {header:?}"
|
||||
);
|
||||
|
||||
// Esc again: parent closes; back to normal composer state.
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
chat.is_normal_backtrack_mode(),
|
||||
"expected to be back in normal composer mode"
|
||||
);
|
||||
}
|
||||
|
||||
fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
|
||||
let height = chat.desired_height(width);
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(chat).render_ref(area, &mut buf);
|
||||
let mut row = String::new();
|
||||
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
|
||||
let y = 1u16.min(height.saturating_sub(1));
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
row.push(' ');
|
||||
} else {
|
||||
row.push_str(s);
|
||||
}
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_history_extends_previous_when_consecutive() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
Reference in New Issue
Block a user