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:
dedrisian-oai
2025-09-21 20:18:35 -07:00
committed by GitHub
parent a4ebd069e5
commit 5996ee0e5f
12 changed files with 1232 additions and 115 deletions

View File

@@ -108,6 +108,61 @@ pub async fn collect_git_info(cwd: &Path) -> Option<GitInfo> {
Some(git_info)
}
/// A minimal commit summary entry used for pickers (subject + timestamp + sha).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CommitLogEntry {
pub sha: String,
/// Unix timestamp (seconds since epoch) of the commit time (committer time).
pub timestamp: i64,
/// Single-line subject of the commit message.
pub subject: String,
}
/// Return the last `limit` commits reachable from HEAD for the current branch.
/// Each entry contains the SHA, commit timestamp (seconds), and subject line.
/// Returns an empty vector if not in a git repo or on error/timeout.
pub async fn recent_commits(cwd: &Path, limit: usize) -> Vec<CommitLogEntry> {
// Ensure we're in a git repo first to avoid noisy errors.
let Some(out) = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd).await else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let fmt = "%H%x1f%ct%x1f%s"; // <sha> <US> <commit_time> <US> <subject>
let n = limit.max(1).to_string();
let Some(log_out) =
run_git_command_with_timeout(&["log", "-n", &n, &format!("--pretty=format:{fmt}")], cwd)
.await
else {
return Vec::new();
};
if !log_out.status.success() {
return Vec::new();
}
let text = String::from_utf8_lossy(&log_out.stdout);
let mut entries: Vec<CommitLogEntry> = Vec::new();
for line in text.lines() {
let mut parts = line.split('\u{001f}');
let sha = parts.next().unwrap_or("").trim();
let ts_s = parts.next().unwrap_or("").trim();
let subject = parts.next().unwrap_or("").trim();
if sha.is_empty() || ts_s.is_empty() {
continue;
}
let timestamp = ts_s.parse::<i64>().unwrap_or(0);
entries.push(CommitLogEntry {
sha: sha.to_string(),
timestamp,
subject: subject.to_string(),
});
}
entries
}
/// Returns the closest git sha to HEAD that is on a remote as well as the diff to that sha.
pub async fn git_diff_to_remote(cwd: &Path) -> Option<GitDiffToRemote> {
get_git_repo_root(cwd)?;
@@ -202,6 +257,11 @@ async fn get_default_branch(cwd: &Path) -> Option<String> {
}
// No remote-derived default; try common local defaults if they exist
get_default_branch_local(cwd).await
}
/// Attempt to determine the repository's default branch name from local branches.
async fn get_default_branch_local(cwd: &Path) -> Option<String> {
for candidate in ["main", "master"] {
if let Some(verify) = run_git_command_with_timeout(
&[
@@ -485,6 +545,46 @@ pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
git_dir_path.parent().map(Path::to_path_buf)
}
/// Returns a list of local git branches.
/// Includes the default branch at the beginning of the list, if it exists.
pub async fn local_git_branches(cwd: &Path) -> Vec<String> {
let mut branches: Vec<String> = if let Some(out) =
run_git_command_with_timeout(&["branch", "--format=%(refname:short)"], cwd).await
&& out.status.success()
{
String::from_utf8_lossy(&out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
} else {
Vec::new()
};
branches.sort_unstable();
if let Some(base) = get_default_branch_local(cwd).await
&& let Some(pos) = branches.iter().position(|name| name == &base)
{
let base_branch = branches.remove(pos);
branches.insert(0, base_branch);
}
branches
}
/// Returns the current checked out branch name.
pub async fn current_branch_name(cwd: &Path) -> Option<String> {
let out = run_git_command_with_timeout(&["branch", "--show-current"], cwd).await?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout)
.ok()
.map(|s| s.trim().to_string())
.filter(|name| !name.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -551,6 +651,80 @@ mod tests {
repo_path
}
#[tokio::test]
async fn test_recent_commits_non_git_directory_returns_empty() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let entries = recent_commits(temp_dir.path(), 10).await;
assert!(entries.is_empty(), "expected no commits outside a git repo");
}
#[tokio::test]
async fn test_recent_commits_orders_and_limits() {
use tokio::time::Duration;
use tokio::time::sleep;
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let repo_path = create_test_git_repo(&temp_dir).await;
// Make three distinct commits with small delays to ensure ordering by timestamp.
fs::write(repo_path.join("file.txt"), "one").unwrap();
Command::new("git")
.args(["add", "file.txt"])
.current_dir(&repo_path)
.output()
.await
.expect("git add");
Command::new("git")
.args(["commit", "-m", "first change"])
.current_dir(&repo_path)
.output()
.await
.expect("git commit 1");
sleep(Duration::from_millis(1100)).await;
fs::write(repo_path.join("file.txt"), "two").unwrap();
Command::new("git")
.args(["add", "file.txt"])
.current_dir(&repo_path)
.output()
.await
.expect("git add 2");
Command::new("git")
.args(["commit", "-m", "second change"])
.current_dir(&repo_path)
.output()
.await
.expect("git commit 2");
sleep(Duration::from_millis(1100)).await;
fs::write(repo_path.join("file.txt"), "three").unwrap();
Command::new("git")
.args(["add", "file.txt"])
.current_dir(&repo_path)
.output()
.await
.expect("git add 3");
Command::new("git")
.args(["commit", "-m", "third change"])
.current_dir(&repo_path)
.output()
.await
.expect("git commit 3");
// Request the latest 3 commits; should be our three changes in reverse time order.
let entries = recent_commits(&repo_path, 3).await;
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].subject, "third change");
assert_eq!(entries[1].subject, "second change");
assert_eq!(entries[2].subject, "first change");
// Basic sanity on SHA formatting
for e in entries {
assert!(e.sha.len() >= 7 && e.sha.chars().all(|c| c.is_ascii_hexdigit()));
}
}
async fn create_test_git_repo_with_remote(temp_dir: &TempDir) -> (PathBuf, String) {
let repo_path = create_test_git_repo(temp_dir).await;
let remote_path = temp_dir.path().join("remote.git");

View File

@@ -354,6 +354,18 @@ impl App {
AppEvent::UpdateSandboxPolicy(policy) => {
self.chat_widget.set_sandbox_policy(policy);
}
AppEvent::OpenReviewBranchPicker(cwd) => {
self.chat_widget.show_review_branch_picker(&cwd).await;
}
AppEvent::OpenReviewCommitPicker(cwd) => {
self.chat_widget.show_review_commit_picker(&cwd).await;
}
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::OpenReviewPopup => {
self.chat_widget.open_review_popup();
}
}
Ok(true)
}

View File

@@ -1,3 +1,5 @@
use std::path::PathBuf;
use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
use codex_file_search::FileMatch;
@@ -65,4 +67,16 @@ pub(crate) enum AppEvent {
/// Forwarded conversation history snapshot from the current conversation.
ConversationHistory(ConversationPathResponseEvent),
/// Open the branch picker option from the review popup.
OpenReviewBranchPicker(PathBuf),
/// Open the commit picker option from the review popup.
OpenReviewCommitPicker(PathBuf),
/// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt,
/// Open the top-level review presets popup.
OpenReviewPopup,
}

View File

@@ -28,6 +28,17 @@ pub(crate) trait BottomPaneView {
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Optional paste handler. Return true if the view modified its state and
/// needs a redraw.
fn handle_paste(&mut self, _pane: &mut BottomPane, _pasted: String) -> bool {
false
}
/// Cursor position when this view is active.
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> {
None
}
/// Try to handle approval request; return the original value if not
/// consumed.
fn try_consume_approval_request(

View File

@@ -0,0 +1,254 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::cell::RefCell;
use super::popup_consts::STANDARD_POPUP_HINT_LINE;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::SelectionAction;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::textarea::TextArea;
use super::textarea::TextAreaState;
/// Callback invoked when the user submits a custom prompt.
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
/// Minimal multi-line text input view to collect custom review instructions.
pub(crate) struct CustomPromptView {
title: String,
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
app_event_tx: AppEventSender,
on_escape: Option<SelectionAction>,
// UI state
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
complete: bool,
}
impl CustomPromptView {
pub(crate) fn new(
title: String,
placeholder: String,
context_label: Option<String>,
app_event_tx: AppEventSender,
on_escape: Option<SelectionAction>,
on_submit: PromptSubmitted,
) -> Self {
Self {
title,
placeholder,
context_label,
on_submit,
app_event_tx,
on_escape,
textarea: TextArea::new(),
textarea_state: RefCell::new(TextAreaState::default()),
complete: false,
}
}
}
impl BottomPaneView for CustomPromptView {
fn handle_key_event(&mut self, _pane: &mut super::BottomPane, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c(_pane);
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => {
let text = self.textarea.text().trim().to_string();
if !text.is_empty() {
(self.on_submit)(text);
self.complete = true;
}
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.textarea.input(key_event);
}
other => {
self.textarea.input(other);
}
}
}
fn on_ctrl_c(&mut self, _pane: &mut super::BottomPane) -> CancellationEvent {
self.complete = true;
if let Some(cb) = &self.on_escape {
cb(&self.app_event_tx);
}
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.complete
}
fn desired_height(&self, width: u16) -> u16 {
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
1u16 + extra_top + self.input_height(width) + 3u16
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let input_height = self.input_height(area.width);
// Title line
let title_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let title_spans: Vec<Span<'static>> = vec![gutter(), self.title.clone().bold()];
Paragraph::new(Line::from(title_spans)).render(title_area, buf);
// Optional context line
let mut input_y = area.y.saturating_add(1);
if let Some(context_label) = &self.context_label {
let context_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: 1,
};
let spans: Vec<Span<'static>> = vec![gutter(), context_label.clone().cyan()];
Paragraph::new(Line::from(spans)).render(context_area, buf);
input_y = input_y.saturating_add(1);
}
// Input line
let input_area = Rect {
x: area.x,
y: input_y,
width: area.width,
height: input_height,
};
if input_area.width >= 2 {
for row in 0..input_area.height {
Paragraph::new(Line::from(vec![gutter()])).render(
Rect {
x: input_area.x,
y: input_area.y.saturating_add(row),
width: 2,
height: 1,
},
buf,
);
}
let text_area_height = input_area.height.saturating_sub(1);
if text_area_height > 0 {
if input_area.width > 2 {
let blank_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y,
width: input_area.width.saturating_sub(2),
height: 1,
};
Clear.render(blank_rect, buf);
}
let textarea_rect = Rect {
x: input_area.x.saturating_add(2),
y: input_area.y.saturating_add(1),
width: input_area.width.saturating_sub(2),
height: text_area_height,
};
let mut state = self.textarea_state.borrow_mut();
StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state);
if self.textarea.text().is_empty() {
Paragraph::new(Line::from(self.placeholder.clone().dim()))
.render(textarea_rect, buf);
}
}
}
let hint_blank_y = input_area.y.saturating_add(input_height);
if hint_blank_y < area.y.saturating_add(area.height) {
let blank_area = Rect {
x: area.x,
y: hint_blank_y,
width: area.width,
height: 1,
};
Clear.render(blank_area, buf);
}
let hint_y = hint_blank_y.saturating_add(1);
if hint_y < area.y.saturating_add(area.height) {
Paragraph::new(STANDARD_POPUP_HINT_LINE).render(
Rect {
x: area.x,
y: hint_y,
width: area.width,
height: 1,
},
buf,
);
}
}
fn handle_paste(&mut self, _pane: &mut super::BottomPane, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, &state)
}
}
impl CustomPromptView {
fn input_height(&self, width: u16) -> u16 {
let usable_width = width.saturating_sub(2);
let text_height = self.textarea.desired_height(usable_width).clamp(1, 8);
text_height.saturating_add(1).min(9)
}
}
fn gutter() -> Span<'static> {
"".cyan()
}

View File

@@ -28,6 +28,19 @@ pub(crate) struct SelectionItem {
pub description: Option<String>,
pub is_current: bool,
pub actions: Vec<SelectionAction>,
pub dismiss_on_select: bool,
pub search_value: Option<String>,
}
#[derive(Default)]
pub(crate) struct SelectionViewParams {
pub title: String,
pub subtitle: Option<String>,
pub footer_hint: Option<String>,
pub items: Vec<SelectionItem>,
pub is_searchable: bool,
pub search_placeholder: Option<String>,
pub on_escape: Option<SelectionAction>,
}
pub(crate) struct ListSelectionView {
@@ -38,6 +51,11 @@ pub(crate) struct ListSelectionView {
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
on_escape: Option<SelectionAction>,
is_searchable: bool,
search_query: String,
search_placeholder: Option<String>,
filtered_indices: Vec<usize>,
}
impl ListSelectionView {
@@ -49,49 +67,145 @@ impl ListSelectionView {
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
para.render(area, buf);
}
pub fn new(
title: String,
subtitle: Option<String>,
footer_hint: Option<String>,
items: Vec<SelectionItem>,
app_event_tx: AppEventSender,
) -> Self {
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
let mut s = Self {
title,
subtitle,
footer_hint,
items,
title: params.title,
subtitle: params.subtitle,
footer_hint: params.footer_hint,
items: params.items,
state: ScrollState::new(),
complete: false,
app_event_tx,
on_escape: params.on_escape,
is_searchable: params.is_searchable,
search_query: String::new(),
search_placeholder: if params.is_searchable {
params.search_placeholder
} else {
None
},
filtered_indices: Vec::new(),
};
let len = s.items.len();
if let Some(idx) = s.items.iter().position(|it| it.is_current) {
s.state.selected_idx = Some(idx);
}
s.state.clamp_selection(len);
s.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
s.apply_filter();
s
}
fn visible_len(&self) -> usize {
self.filtered_indices.len()
}
fn max_visible_rows(len: usize) -> usize {
MAX_POPUP_ROWS.min(len.max(1))
}
fn apply_filter(&mut self) {
let previously_selected = self
.state
.selected_idx
.and_then(|visible_idx| self.filtered_indices.get(visible_idx).copied())
.or_else(|| {
(!self.is_searchable)
.then(|| self.items.iter().position(|item| item.is_current))
.flatten()
});
if self.is_searchable && !self.search_query.is_empty() {
let query_lower = self.search_query.to_lowercase();
self.filtered_indices = self
.items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
let matches = if let Some(search_value) = &item.search_value {
search_value.to_lowercase().contains(&query_lower)
} else {
let mut matches = item.name.to_lowercase().contains(&query_lower);
if !matches && let Some(desc) = &item.description {
matches = desc.to_lowercase().contains(&query_lower);
}
matches
};
matches.then_some(idx)
})
.collect();
} else {
self.filtered_indices = (0..self.items.len()).collect();
}
let len = self.filtered_indices.len();
self.state.selected_idx = self
.state
.selected_idx
.and_then(|visible_idx| {
self.filtered_indices
.get(visible_idx)
.and_then(|idx| self.filtered_indices.iter().position(|cur| cur == idx))
})
.or_else(|| {
previously_selected.and_then(|actual_idx| {
self.filtered_indices
.iter()
.position(|idx| *idx == actual_idx)
})
})
.or_else(|| (len > 0).then_some(0));
let visible = Self::max_visible_rows(len);
self.state.clamp_selection(len);
self.state.ensure_visible(len, visible);
}
fn build_rows(&self) -> Vec<GenericDisplayRow> {
self.filtered_indices
.iter()
.enumerate()
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '>' } else { ' ' };
let name = item.name.as_str();
let name_with_marker = if item.is_current {
format!("{name} (current)")
} else {
item.name.clone()
};
let n = visible_idx + 1;
let display_name = format!("{prefix} {n}. {name_with_marker}");
GenericDisplayRow {
name: display_name,
match_indices: None,
is_current: item.is_current,
description: item.description.clone(),
}
})
})
.collect()
}
fn move_up(&mut self) {
let len = self.items.len();
let len = self.visible_len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
fn move_down(&mut self) {
let len = self.items.len();
let len = self.visible_len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
let visible = Self::max_visible_rows(len);
self.state.ensure_visible(len, visible);
}
fn accept(&mut self) {
if let Some(idx) = self.state.selected_idx {
if let Some(item) = self.items.get(idx) {
for act in &item.actions {
act(&self.app_event_tx);
}
if let Some(idx) = self.state.selected_idx
&& let Some(actual_idx) = self.filtered_indices.get(idx)
&& let Some(item) = self.items.get(*actual_idx)
{
for act in &item.actions {
act(&self.app_event_tx);
}
if item.dismiss_on_select {
self.complete = true;
}
} else {
@@ -99,9 +213,10 @@ impl ListSelectionView {
}
}
fn cancel(&mut self) {
// Close the popup without performing any actions.
self.complete = true;
#[cfg(test)]
pub(crate) fn set_search_query(&mut self, query: String) {
self.search_query = query;
self.apply_filter();
}
}
@@ -115,9 +230,29 @@ impl BottomPaneView for ListSelectionView {
code: KeyCode::Down,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Backspace,
..
} if self.is_searchable => {
self.search_query.pop();
self.apply_filter();
}
KeyEvent {
code: KeyCode::Esc, ..
} => self.cancel(),
} => {
self.on_ctrl_c(_pane);
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if self.is_searchable
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
self.search_query.push(c);
self.apply_filter();
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
@@ -133,39 +268,25 @@ impl BottomPaneView for ListSelectionView {
fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent {
self.complete = true;
if let Some(cb) = &self.on_escape {
cb(&self.app_event_tx);
}
CancellationEvent::Handled
}
fn desired_height(&self, width: u16) -> u16 {
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
// Build the same display rows used by the renderer so wrapping math matches.
let rows: Vec<GenericDisplayRow> = self
.items
.iter()
.enumerate()
.map(|(i, it)| {
let is_selected = self.state.selected_idx == Some(i);
let prefix = if is_selected { '>' } else { ' ' };
let name_with_marker = if it.is_current {
format!("{} (current)", it.name)
} else {
it.name.clone()
};
let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker);
GenericDisplayRow {
name: display_name,
match_indices: None,
is_current: it.is_current,
description: it.description.clone(),
}
})
.collect();
let rows = self.build_rows();
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
// +1 for the title row, +1 for a spacer line beneath the header,
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
let mut height = rows_height + 2;
if self.is_searchable {
height = height.saturating_add(1);
}
if self.subtitle.is_some() {
// +1 for subtitle (the spacer is accounted for above)
height = height.saturating_add(1);
@@ -194,6 +315,25 @@ impl BottomPaneView for ListSelectionView {
title_para.render(title_area, buf);
let mut next_y = area.y.saturating_add(1);
if self.is_searchable {
let search_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let query_span: Span<'static> = if self.search_query.is_empty() {
self.search_placeholder
.as_ref()
.map(|placeholder| placeholder.clone().dim())
.unwrap_or_else(|| "".into())
} else {
self.search_query.clone().into()
};
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
.render(search_area, buf);
next_y = next_y.saturating_add(1);
}
if let Some(sub) = &self.subtitle {
let subtitle_area = Rect {
x: area.x,
@@ -228,27 +368,7 @@ impl BottomPaneView for ListSelectionView {
.saturating_sub(footer_reserved),
};
let rows: Vec<GenericDisplayRow> = self
.items
.iter()
.enumerate()
.map(|(i, it)| {
let is_selected = self.state.selected_idx == Some(i);
let prefix = if is_selected { '>' } else { ' ' };
let name_with_marker = if it.is_current {
format!("{} (current)", it.name)
} else {
it.name.clone()
};
let display_name = format!("{} {}. {}", prefix, i + 1, name_with_marker);
GenericDisplayRow {
name: display_name,
match_indices: None,
is_current: it.is_current,
description: it.description.clone(),
}
})
.collect();
let rows = self.build_rows();
if rows_area.height > 0 {
render_rows(
rows_area,
@@ -279,6 +399,7 @@ mod tests {
use super::BottomPaneView;
use super::*;
use crate::app_event::AppEvent;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
use insta::assert_snapshot;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
@@ -292,19 +413,26 @@ mod tests {
description: Some("Codex can read files".to_string()),
is_current: true,
actions: vec![],
dismiss_on_select: true,
search_value: None,
},
SelectionItem {
name: "Full Access".to_string(),
description: Some("Codex can edit files".to_string()),
is_current: false,
actions: vec![],
dismiss_on_select: true,
search_value: None,
},
];
ListSelectionView::new(
"Select Approval Mode".to_string(),
subtitle.map(str::to_string),
Some("Press Enter to confirm or Esc to go back".to_string()),
items,
SelectionViewParams {
title: "Select Approval Mode".to_string(),
subtitle: subtitle.map(str::to_string),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
..Default::default()
},
tx,
)
}
@@ -347,4 +475,33 @@ mod tests {
let view = make_selection_view(Some("Switch between Codex approval presets"));
assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view));
}
#[test]
fn renders_search_query_line_when_enabled() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let items = vec![SelectionItem {
name: "Read Only".to_string(),
description: Some("Codex can read files".to_string()),
is_current: false,
actions: vec![],
dismiss_on_select: true,
search_value: None,
}];
let mut view = ListSelectionView::new(
SelectionViewParams {
title: "Select Approval Mode".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
..Default::default()
},
tx,
);
view.set_search_query("filters".to_string());
let lines = render_lines(&view);
assert!(lines.contains("▌ filters"));
}
}

View File

@@ -20,10 +20,12 @@ mod bottom_pane_view;
mod chat_composer;
mod chat_composer_history;
mod command_popup;
pub mod custom_prompt_view;
mod file_search_popup;
mod list_selection_view;
pub(crate) use list_selection_view::SelectionViewParams;
mod paste_burst;
mod popup_consts;
pub mod popup_consts;
mod scroll_state;
mod selection_popup_common;
mod textarea;
@@ -148,10 +150,10 @@ impl BottomPane {
// status indicator shown while a task is running, or approval modal).
// In these states the textarea is not interactable, so we should not
// show its caret.
if self.active_view.is_some() {
None
let [_, content] = self.layout(area);
if let Some(view) = self.active_view.as_ref() {
view.cursor_pos(content)
} else {
let [_, content] = self.layout(area);
self.composer.cursor_pos(content)
}
}
@@ -224,7 +226,17 @@ impl BottomPane {
}
pub fn handle_paste(&mut self, pasted: String) {
if self.active_view.is_none() {
if let Some(mut view) = self.active_view.take() {
let needs_redraw = view.handle_paste(self, pasted);
if view.is_complete() {
self.on_active_view_complete();
} else {
self.active_view = Some(view);
}
if needs_redraw {
self.request_redraw();
}
} else {
let needs_redraw = self.composer.handle_paste(pasted);
if needs_redraw {
self.request_redraw();
@@ -318,22 +330,9 @@ impl BottomPane {
}
/// Show a generic list selection view with the provided items.
pub(crate) fn show_selection_view(
&mut self,
title: String,
subtitle: Option<String>,
footer_hint: Option<String>,
items: Vec<SelectionItem>,
) {
let view = list_selection_view::ListSelectionView::new(
title,
subtitle,
footer_hint,
items,
self.app_event_tx.clone(),
);
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
self.active_view = Some(Box::new(view));
self.request_redraw();
}
/// Update the queued messages shown under the status header.
@@ -373,6 +372,11 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.active_view = Some(view);
self.request_redraw();
}
/// Called when the agent requests user approval.
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
let request = if let Some(view) = self.active_view.as_mut() {

View File

@@ -3,3 +3,6 @@
/// Maximum number of rows any popup should attempt to display.
/// Keep this consistent across all popups for a uniform feel.
pub(crate) const MAX_POPUP_ROWS: usize = 8;
/// Standard footer hint text used by popups.
pub(crate) const STANDARD_POPUP_HINT_LINE: &str = "Press Enter to confirm or Esc to go back";

View File

@@ -13,6 +13,7 @@ use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar;
use super::scroll_state::ScrollState;
use crate::ui_consts::LIVE_PREFIX_COLS;
@@ -55,10 +56,24 @@ fn compute_desc_col(
/// at `desc_col`. Applies fuzzy-match bolding when indices are present and
/// dims the description.
fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
// Enforce single-line name: allow at most desc_col - 2 cells for name,
// reserving two spaces before the description column.
let name_limit = desc_col.saturating_sub(2);
let mut name_spans: Vec<Span> = Vec::with_capacity(row.name.len());
let mut used_width = 0usize;
let mut truncated = false;
if let Some(idxs) = row.match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in row.name.chars().enumerate() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used_width + ch_w > name_limit {
truncated = true;
break;
}
used_width += ch_w;
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
name_spans.push(ch.to_string().bold());
@@ -67,7 +82,21 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
}
}
} else {
name_spans.push(row.name.clone().into());
for ch in row.name.chars() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used_width + ch_w > name_limit {
truncated = true;
break;
}
used_width += ch_w;
name_spans.push(ch.to_string().into());
}
}
if truncated {
// If there is at least one cell available, add an ellipsis.
// When name_limit is 0, we still show an ellipsis to indicate truncation.
name_spans.push("".into());
}
let this_name_width = Line::from(name_spans.clone()).width();

View File

@@ -5,6 +5,8 @@ use std::sync::Arc;
use codex_core::config::Config;
use codex_core::config_types::Notifications;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::local_git_branches;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
@@ -63,6 +65,9 @@ use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::diff_render::display_path_for;
use crate::get_git_diff::get_git_diff;
@@ -87,6 +92,7 @@ mod session_header;
use self::session_header::SessionHeader;
use crate::streaming::controller::AppEventHistorySink;
use crate::streaming::controller::StreamController;
//
use codex_common::approval_presets::ApprovalPreset;
use codex_common::approval_presets::builtin_approval_presets;
use codex_common::model_presets::ModelPreset;
@@ -97,6 +103,7 @@ use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_file_search::FileMatch;
use std::path::Path;
// Track information about an in-flight exec command.
struct RunningCommand {
@@ -941,13 +948,7 @@ 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(),
},
});
self.open_review_popup();
}
SlashCommand::Model => {
self.open_model_popup();
@@ -1417,15 +1418,20 @@ impl ChatWidget {
description,
is_current,
actions,
dismiss_on_select: true,
search_value: None,
});
}
self.bottom_pane.show_selection_view(
"Select model and reasoning level".to_string(),
Some("Switch between OpenAI models for this and future Codex CLI session".to_string()),
Some("Press Enter to confirm or Esc to go back".to_string()),
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select model and reasoning level".to_string(),
subtitle: Some(
"Switch between OpenAI models for this and future Codex CLI session".to_string(),
),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
);
..Default::default()
});
}
/// Open a popup to choose the approvals mode (ask for approval policy + sandbox policy).
@@ -1458,15 +1464,17 @@ impl ChatWidget {
description,
is_current,
actions,
dismiss_on_select: true,
search_value: None,
});
}
self.bottom_pane.show_selection_view(
"Select Approval Mode".to_string(),
None,
Some("Press Enter to confirm or Esc to go back".to_string()),
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select Approval Mode".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
);
..Default::default()
});
}
/// Set the approval policy in the widget's config copy.
@@ -1575,6 +1583,181 @@ impl ChatWidget {
self.bottom_pane.set_custom_prompts(ev.custom_prompts);
}
pub(crate) fn open_review_popup(&mut self) {
let mut items: Vec<SelectionItem> = Vec::new();
items.push(SelectionItem {
name: "Review uncommitted changes".to_string(),
description: None,
is_current: false,
actions: vec![Box::new(
move |tx: &AppEventSender| {
tx.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt: "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.".to_string(),
user_facing_hint: "current changes".to_string(),
},
}));
},
)],
dismiss_on_select: true,
search_value: None,
});
// New: Review a specific commit (opens commit picker)
items.push(SelectionItem {
name: "Review a commit".to_string(),
description: None,
is_current: false,
actions: vec![Box::new({
let cwd = self.config.cwd.clone();
move |tx| {
tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone()));
}
})],
dismiss_on_select: false,
search_value: None,
});
items.push(SelectionItem {
name: "Review against a base branch".to_string(),
description: None,
is_current: false,
actions: vec![Box::new({
let cwd = self.config.cwd.clone();
move |tx| {
tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone()));
}
})],
dismiss_on_select: false,
search_value: None,
});
items.push(SelectionItem {
name: "Custom review instructions".to_string(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::OpenReviewCustomPrompt);
})],
dismiss_on_select: false,
search_value: None,
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a review preset".into(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
on_escape: None,
..Default::default()
});
}
pub(crate) async fn show_review_branch_picker(&mut self, cwd: &Path) {
let branches = local_git_branches(cwd).await;
let current_branch = current_branch_name(cwd)
.await
.unwrap_or_else(|| "(detached HEAD)".to_string());
let mut items: Vec<SelectionItem> = Vec::with_capacity(branches.len());
for option in branches {
let branch = option.clone();
items.push(SelectionItem {
name: format!("{current_branch} -> {branch}"),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
tx3.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt: format!(
"Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch} e.g. (git merge-base HEAD {branch}), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings."
),
user_facing_hint: format!("changes against '{branch}'"),
},
}));
})],
dismiss_on_select: true,
search_value: Some(option),
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a base branch".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to search branches".to_string()),
on_escape: Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))),
..Default::default()
});
}
pub(crate) async fn show_review_commit_picker(&mut self, cwd: &Path) {
let commits = codex_core::git_info::recent_commits(cwd, 100).await;
let mut items: Vec<SelectionItem> = Vec::with_capacity(commits.len());
for entry in commits {
let subject = entry.subject.clone();
let sha = entry.sha.clone();
let short = sha.chars().take(7).collect::<String>();
let search_val = format!("{subject} {sha}");
items.push(SelectionItem {
name: subject.clone(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
let hint = format!("commit {short}");
let prompt = format!(
"Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings."
);
tx3.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt,
user_facing_hint: hint,
},
}));
})],
dismiss_on_select: true,
search_value: Some(search_val),
});
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a commit to review".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
on_escape: Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))),
..Default::default()
});
}
pub(crate) fn show_review_custom_prompt(&mut self) {
let tx = self.app_event_tx.clone();
let view = CustomPromptView::new(
"Custom review instructions".to_string(),
"Type instructions and press Enter".to_string(),
None,
self.app_event_tx.clone(),
Some(Box::new(|tx| tx.send(AppEvent::OpenReviewPopup))),
Box::new(move |prompt: String| {
let trimmed = prompt.trim().to_string();
if trimmed.is_empty() {
return;
}
tx.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt: trimmed.clone(),
user_facing_hint: trimmed,
},
}));
}),
);
self.bottom_pane.show_view(Box::new(view));
}
/// Programmatically submit a user text message as if typed in the
/// composer. The text will be added to conversation history and sent to
/// the agent.
@@ -1731,5 +1914,48 @@ fn extract_first_bold(s: &str) -> Option<String> {
None
}
#[cfg(test)]
pub(crate) fn show_review_commit_picker_with_entries(
chat: &mut ChatWidget,
entries: Vec<codex_core::git_info::CommitLogEntry>,
) {
let mut items: Vec<SelectionItem> = Vec::with_capacity(entries.len());
for entry in entries {
let subject = entry.subject.clone();
let sha = entry.sha.clone();
let short = sha.chars().take(7).collect::<String>();
let search_val = format!("{subject} {sha}");
items.push(SelectionItem {
name: subject.clone(),
description: None,
is_current: false,
actions: vec![Box::new(move |tx3: &AppEventSender| {
let hint = format!("commit {short}");
let prompt = format!(
"Review the code changes introduced by commit {sha} (\"{subject}\"). Provide prioritized, actionable findings."
);
tx3.send(AppEvent::CodexOp(Op::Review {
review_request: ReviewRequest {
prompt,
user_facing_hint: hint,
},
}));
})],
dismiss_on_select: true,
search_value: Some(search_val),
});
}
chat.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a commit to review".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
search_placeholder: Some("Type to search commits".to_string()),
..Default::default()
});
}
#[cfg(test)]
pub(crate) mod tests;

View File

@@ -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();

View File

@@ -36,7 +36,7 @@ impl SlashCommand {
SlashCommand::New => "start a new chat during a conversation",
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Review => "review my changes and find issues",
SlashCommand::Quit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",