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:
@@ -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(
|
||||
|
||||
254
codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal file
254
codex-rs/tui/src/bottom_pane/custom_prompt_view.rs
Normal 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()
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user