/review options

This commit is contained in:
Daniel Edrisian
2025-09-19 15:37:14 -07:00
parent ff389dc52f
commit 5d66eb2a1c
11 changed files with 916 additions and 90 deletions

View File

@@ -202,6 +202,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 +490,53 @@ 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: &PathBuf) -> Vec<String> {
let out = std::process::Command::new("git")
.args(["branch", "--format=%(refname:short)"])
.current_dir(cwd)
.output();
let mut branches: Vec<String> = match out {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
_ => Vec::new(),
};
branches.sort_unstable();
if let Some(base) = get_default_branch_local(cwd.as_path()).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 fn current_branch_name(cwd: &PathBuf) -> Option<String> {
let out = std::process::Command::new("git")
.args(["branch", "--show-current"])
.current_dir(cwd)
.output()
.ok()?;
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::*;

View File

@@ -355,6 +355,12 @@ 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::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
}
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,10 @@ 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 custom prompt option from the review popup.
OpenReviewCustomPrompt,
}

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,238 @@
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 crate::chatwidget::STANDARD_POPUP_HINT_LINE;
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,
// UI state
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
complete: bool,
}
impl CustomPromptView {
pub(crate) fn new(
title: String,
placeholder: String,
context_label: Option<String>,
on_submit: PromptSubmitted,
) -> Self {
Self {
title,
placeholder,
context_label,
on_submit,
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::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;
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.to_string()).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,8 @@ pub(crate) struct SelectionItem {
pub description: Option<String>,
pub is_current: bool,
pub actions: Vec<SelectionAction>,
pub close_on_select: bool,
pub search_value: Option<String>,
}
pub(crate) struct ListSelectionView {
@@ -38,6 +40,11 @@ pub(crate) struct ListSelectionView {
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
is_searchable: bool,
search_query: String,
search_placeholder: Option<String>,
empty_message: Option<String>,
filtered_indices: Vec<usize>,
}
impl ListSelectionView {
@@ -49,11 +56,15 @@ 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>,
is_searchable: bool,
search_placeholder: Option<String>,
empty_message: Option<String>,
app_event_tx: AppEventSender,
) -> Self {
let mut s = Self {
@@ -64,34 +75,110 @@ impl ListSelectionView {
state: ScrollState::new(),
complete: false,
app_event_tx,
is_searchable,
search_query: String::new(),
search_placeholder: if is_searchable {
search_placeholder
} else {
None
},
empty_message,
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 {
if 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 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.close_on_select {
self.complete = true;
}
} else {
@@ -99,9 +186,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();
}
}
@@ -116,8 +204,23 @@ impl BottomPaneView for ListSelectionView {
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Esc, ..
} => self.cancel(),
code: KeyCode::Backspace,
..
} if self.is_searchable => {
self.search_query.pop();
self.apply_filter();
}
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,
@@ -140,24 +243,27 @@ impl BottomPaneView for ListSelectionView {
// 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
.filtered_indices
.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(),
}
.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_with_marker = if item.is_current {
format!("{} (current)", item.name)
} else {
item.name.clone()
};
let display_name =
format!("{} {}. {}", prefix, visible_idx + 1, name_with_marker);
GenericDisplayRow {
name: display_name,
match_indices: None,
is_current: item.is_current,
description: item.description.clone(),
}
})
})
.collect();
@@ -166,6 +272,9 @@ impl BottomPaneView for ListSelectionView {
// +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 +303,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,
@@ -229,24 +357,27 @@ impl BottomPaneView for ListSelectionView {
};
let rows: Vec<GenericDisplayRow> = self
.items
.filtered_indices
.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(),
}
.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_with_marker = if item.is_current {
format!("{} (current)", item.name)
} else {
item.name.clone()
};
let display_name =
format!("{} {}. {}", prefix, visible_idx + 1, name_with_marker);
GenericDisplayRow {
name: display_name,
match_indices: None,
is_current: item.is_current,
description: item.description.clone(),
}
})
})
.collect();
if rows_area.height > 0 {
@@ -257,7 +388,7 @@ impl BottomPaneView for ListSelectionView {
&self.state,
MAX_POPUP_ROWS,
true,
"no matches",
self.empty_message.as_deref().unwrap_or("no matches"),
);
}
@@ -279,6 +410,7 @@ mod tests {
use super::BottomPaneView;
use super::*;
use crate::app_event::AppEvent;
use crate::chatwidget::STANDARD_POPUP_HINT_LINE;
use insta::assert_snapshot;
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
@@ -292,19 +424,26 @@ mod tests {
description: Some("Codex can read files".to_string()),
is_current: true,
actions: vec![],
close_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![],
close_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()),
Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
false,
None,
None,
tx,
)
}
@@ -347,4 +486,32 @@ 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![],
close_on_select: true,
search_value: None,
}];
let mut view = ListSelectionView::new(
"Select Approval Mode".to_string(),
None,
Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
true,
Some("Type to search branches".to_string()),
Some("no matches".to_string()),
tx,
);
view.set_search_query("filters".to_string());
let lines = render_lines(&view);
assert!(lines.contains("▌ filters"));
}
}

View File

@@ -7,6 +7,7 @@ use crate::user_approval_widget::ApprovalRequest;
use bottom_pane_view::BottomPaneView;
use codex_core::protocol::TokenUsageInfo;
use codex_file_search::FileMatch;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
@@ -20,6 +21,7 @@ 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;
mod paste_burst;
@@ -49,8 +51,8 @@ pub(crate) struct BottomPane {
/// input state is retained when the view is closed.
composer: ChatComposer,
/// If present, this is displayed instead of the `composer` (e.g. modals).
active_view: Option<Box<dyn BottomPaneView>>,
/// Stack of views displayed instead of the composer (e.g. popups/modals).
view_stack: Vec<Box<dyn BottomPaneView>>,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
@@ -87,7 +89,7 @@ impl BottomPane {
params.placeholder_text,
params.disable_paste_burst,
),
active_view: None,
view_stack: Vec::new(),
app_event_tx: params.app_event_tx,
frame_requester: params.frame_requester,
has_input_focus: params.has_input_focus,
@@ -99,12 +101,21 @@ impl BottomPane {
}
}
fn active_view(&self) -> Option<&dyn BottomPaneView> {
self.view_stack.last().map(|view| view.as_ref())
}
fn push_view(&mut self, view: Box<dyn BottomPaneView>) {
self.view_stack.push(view);
self.request_redraw();
}
pub fn desired_height(&self, width: u16) -> u16 {
// Always reserve one blank row above the pane for visual spacing.
let top_margin = 1;
// Base height depends on whether a modal/overlay is active.
let base = match self.active_view.as_ref() {
let base = match self.active_view() {
Some(view) => view.desired_height(width),
None => self.composer.desired_height(width).saturating_add(
self.status
@@ -131,7 +142,7 @@ impl BottomPane {
width: area.width,
height: area.height - top_margin - bottom_margin,
};
match self.active_view.as_ref() {
match self.active_view() {
Some(_) => [Rect::ZERO, area],
None => {
let status_height = self
@@ -148,22 +159,33 @@ 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() {
view.cursor_pos(content)
} else {
let [_, content] = self.layout(area);
self.composer.cursor_pos(content)
}
}
/// Forward a key event to the active view or the composer.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
if let Some(mut view) = self.active_view.take() {
// If a modal/view is active, treat Esc like Ctrl+C to let the view
// handle dismissal. Otherwise, forward Esc to the composer so its
// popups (e.g., slash/file search) can process it.
if !self.view_stack.is_empty() && key_event.code == KeyCode::Esc {
self.on_ctrl_c();
return InputResult::None;
}
if let Some(mut view) = self.view_stack.pop() {
let reinsertion_index = self.view_stack.len();
view.handle_key_event(self, key_event);
if !view.is_complete() {
self.active_view = Some(view);
} else {
if view.is_complete() {
self.view_stack.clear();
self.on_active_view_complete();
} else {
let idx = reinsertion_index.min(self.view_stack.len());
self.view_stack.insert(idx, view);
}
self.request_redraw();
InputResult::None
@@ -193,7 +215,7 @@ impl BottomPane {
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
/// chance to consume the event (e.g. to dismiss itself).
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
let mut view = match self.active_view.take() {
let mut view = match self.view_stack.pop() {
Some(view) => view,
None => {
return if self.composer_is_empty() {
@@ -209,22 +231,32 @@ impl BottomPane {
let event = view.on_ctrl_c(self);
match event {
CancellationEvent::Handled => {
if !view.is_complete() {
self.active_view = Some(view);
} else {
if view.is_complete() {
self.on_active_view_complete();
} else {
self.view_stack.push(view);
}
self.show_ctrl_c_quit_hint();
}
CancellationEvent::NotHandled => {
self.active_view = Some(view);
self.view_stack.push(view);
}
}
event
}
pub fn handle_paste(&mut self, pasted: String) {
if self.active_view.is_none() {
if let Some(mut view) = self.view_stack.pop() {
let needs_redraw = view.handle_paste(self, pasted);
if view.is_complete() {
self.on_active_view_complete();
} else {
self.view_stack.push(view);
}
if needs_redraw {
self.request_redraw();
}
} else {
let needs_redraw = self.composer.handle_paste(pasted);
if needs_redraw {
self.request_redraw();
@@ -324,16 +356,21 @@ impl BottomPane {
subtitle: Option<String>,
footer_hint: Option<String>,
items: Vec<SelectionItem>,
is_searchable: bool,
search_placeholder: Option<String>,
empty_message: Option<String>,
) {
let view = list_selection_view::ListSelectionView::new(
title,
subtitle,
footer_hint,
items,
is_searchable,
search_placeholder,
empty_message,
self.app_event_tx.clone(),
);
self.active_view = Some(Box::new(view));
self.request_redraw();
self.push_view(Box::new(view));
}
/// Update the queued messages shown under the status header.
@@ -363,7 +400,7 @@ impl BottomPane {
/// overlays or popups and not running a task. This is the safe context to
/// use Esc-Esc for backtracking from the main view.
pub(crate) fn is_normal_backtrack_mode(&self) -> bool {
!self.is_task_running && self.active_view.is_none() && !self.composer.popup_active()
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
}
/// Update the *context-window remaining* indicator in the composer. This
@@ -373,9 +410,13 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.push_view(view);
}
/// 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() {
let request = if let Some(view) = self.view_stack.last_mut() {
match view.try_consume_approval_request(request) {
Some(request) => request,
None => {
@@ -390,8 +431,7 @@ impl BottomPane {
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
self.pause_status_timer_for_modal();
self.active_view = Some(Box::new(modal));
self.request_redraw()
self.push_view(Box::new(modal));
}
fn on_active_view_complete(&mut self) {
@@ -460,7 +500,7 @@ impl BottomPane {
height: u32,
format_label: &str,
) {
if self.active_view.is_none() {
if self.view_stack.is_empty() {
self.composer
.attach_image(path, width, height, format_label);
self.request_redraw();
@@ -477,7 +517,7 @@ impl WidgetRef for &BottomPane {
let [status_area, content] = self.layout(area);
// When a modal view is active, it owns the whole content area.
if let Some(view) = &self.active_view {
if let Some(view) = self.active_view() {
view.render(content, buf);
} else {
// No active modal:
@@ -587,7 +627,7 @@ mod tests {
// After denial, since the task is still running, the status indicator should be
// visible above the composer. The modal should be gone.
assert!(
pane.active_view.is_none(),
pane.view_stack.is_empty(),
"no active modal view after denial"
);

View File

@@ -0,0 +1,10 @@
---
source: tui/src/bottom_pane/custom_prompt_view.rs
assertion_line: 305
expression: out
---
▌ Custom review instructions
▌ Type instructions and press Enter
Press Enter to confirm or Esc to go back

View File

@@ -0,0 +1,11 @@
---
source: tui/src/bottom_pane/custom_prompt_view.rs
assertion_line: 319
expression: out
---
▌ Custom review instructions
▌ Reviewing current changes
▌ Type instructions and press Enter
Press Enter to confirm or Esc to go back

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;
@@ -62,6 +64,7 @@ use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::bottom_pane::SelectionAction;
use crate::bottom_pane::SelectionItem;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::diff_render::display_path_for;
use crate::get_git_diff::get_git_diff;
@@ -97,6 +100,8 @@ use codex_core::protocol::SandboxPolicy;
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
use codex_file_search::FileMatch;
pub(crate) const STANDARD_POPUP_HINT_LINE: &str = "Press Enter to confirm or Esc to go back";
// Track information about an in-flight exec command.
struct RunningCommand {
command: Vec<String>,
@@ -883,13 +888,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();
@@ -1344,14 +1343,19 @@ impl ChatWidget {
description,
is_current,
actions,
close_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()),
Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
false,
None,
None,
);
}
@@ -1385,14 +1389,19 @@ impl ChatWidget {
description,
is_current,
actions,
close_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()),
Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
false,
None,
None,
);
}
@@ -1502,6 +1511,123 @@ 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 current 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(),
},
}));
},
)],
close_on_select: true,
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()));
}
})],
close_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);
})],
close_on_select: false,
search_value: None,
});
self.bottom_pane.show_selection_view(
"Select a review preset".into(),
None,
Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
false,
None,
None,
);
}
pub(crate) async fn show_review_branch_picker(&mut self, cwd: &PathBuf) {
let branches = local_git_branches(cwd).await;
let current_branch =
current_branch_name(cwd).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 fork point between the current branch and {branch} e.g. (git merge-base HEAD {branch}), then run `git diff` against that fork point to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings."
),
user_facing_hint: format!("changes against '{branch}'"),
},
}));
})],
close_on_select: true,
search_value: Some(option),
});
}
self.bottom_pane.show_selection_view(
"Select a base branch".to_string(),
None,
Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
true,
Some("Type to search branches".to_string()),
Some("no matches".to_string()),
);
}
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,
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.

View File

@@ -644,6 +644,73 @@ 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 third item: "Custom review instructions"
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");
}
/// 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]
@@ -673,6 +740,96 @@ 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, _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));
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, _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));
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();