diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index ca62ad499e..cb7e304618 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -202,6 +202,11 @@ async fn get_default_branch(cwd: &Path) -> Option { } // 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 { 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 { 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 { + let out = std::process::Command::new("git") + .args(["branch", "--format=%(refname:short)"]) + .current_dir(cwd) + .output(); + + let mut branches: Vec = 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 { + 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::*; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bd9150cad0..df7fe442fa 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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) } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 41992cddcc..0d3ccd71c2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, } diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 794dd8c422..de1beaa278 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -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( diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs new file mode 100644 index 0000000000..6c40c95986 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -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; + +/// Minimal multi-line text input view to collect custom review instructions. +pub(crate) struct CustomPromptView { + title: String, + placeholder: String, + context_label: Option, + on_submit: PromptSubmitted, + + // UI state + textarea: TextArea, + textarea_state: RefCell, + complete: bool, +} + +impl CustomPromptView { + pub(crate) fn new( + title: String, + placeholder: String, + context_label: Option, + 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> = 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> = 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() +} diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 5d5dbf0f33..eb1a146fe5 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -28,6 +28,8 @@ pub(crate) struct SelectionItem { pub description: Option, pub is_current: bool, pub actions: Vec, + pub close_on_select: bool, + pub search_value: Option, } 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, + empty_message: Option, + filtered_indices: Vec, } 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, footer_hint: Option, items: Vec, + is_searchable: bool, + search_placeholder: Option, + empty_message: Option, 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 = 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 = 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::(); + 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")); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f30bd418e9..870755fc5e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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>, + /// Stack of views displayed instead of the composer (e.g. popups/modals). + view_stack: Vec>, 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) { + 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, footer_hint: Option, items: Vec, + is_searchable: bool, + search_placeholder: Option, + empty_message: Option, ) { 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) { + 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" ); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__custom_prompt_view__tests__custom_prompt_view_empty.snap.new b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__custom_prompt_view__tests__custom_prompt_view_empty.snap.new new file mode 100644 index 0000000000..92080429c8 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__custom_prompt_view__tests__custom_prompt_view_empty.snap.new @@ -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 diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__custom_prompt_view__tests__custom_prompt_view_with_context.snap.new b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__custom_prompt_view__tests__custom_prompt_view_with_context.snap.new new file mode 100644 index 0000000000..472c85d9d2 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__custom_prompt_view__tests__custom_prompt_view_with_context.snap.new @@ -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 diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e024fd0fab..d020e3d43c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, @@ -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 = 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 = 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. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ffe3f3f707..c1595e23c7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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();