Compare commits

...

2 Commits

Author SHA1 Message Date
Ahmed Ibrahim
7ff835cca5 Reuse list selection menu module 2025-09-11 11:02:57 -07:00
Ahmed Ibrahim
01cf1f7f65 Use shared selection menu for approvals 2025-09-11 10:31:25 -07:00
11 changed files with 357 additions and 347 deletions

View File

@@ -2,10 +2,10 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use super::list_selection_view::GenericDisplayRow;
use super::list_selection_view::ScrollState;
use super::list_selection_view::render_rows;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use codex_common::fuzzy_match::fuzzy_match;

View File

@@ -3,10 +3,10 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use super::list_selection_view::GenericDisplayRow;
use super::list_selection_view::ScrollState;
use super::list_selection_view::render_rows;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
/// Visual state for the file-search popup.
pub(crate) struct FileSearchPopup {

View File

@@ -15,9 +15,16 @@ use super::BottomPane;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use ratatui::layout::Constraint;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
@@ -39,6 +46,190 @@ pub(crate) struct ListSelectionView {
app_event_tx: AppEventSender,
}
/// Generic scroll/selection state for a vertical list menu.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ScrollState {
pub selected_idx: Option<usize>,
pub scroll_top: usize,
}
impl ScrollState {
pub fn new() -> Self {
Self {
selected_idx: None,
scroll_top: 0,
}
}
/// Reset selection and scroll.
pub fn reset(&mut self) {
self.selected_idx = None;
self.scroll_top = 0;
}
/// Clamp selection to be within the [0, len-1] range, or None when empty.
pub fn clamp_selection(&mut self, len: usize) {
self.selected_idx = match len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(len.saturating_sub(1))),
};
if len == 0 {
self.scroll_top = 0;
}
}
/// Move selection up by one, wrapping to the bottom when necessary.
pub fn move_up_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx > 0 => idx - 1,
Some(_) => len - 1,
None => 0,
});
}
/// Move selection down by one, wrapping to the top when necessary.
pub fn move_down_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx + 1 < len => idx + 1,
_ => 0,
});
}
/// Adjust `scroll_top` so that the current `selected_idx` is visible within
/// the window of `visible_rows`.
pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
if len == 0 || visible_rows == 0 {
self.scroll_top = 0;
return;
}
if let Some(sel) = self.selected_idx {
if sel < self.scroll_top {
self.scroll_top = sel;
} else {
let bottom = self.scroll_top + visible_rows - 1;
if sel > bottom {
self.scroll_top = sel + 1 - visible_rows;
}
}
} else {
self.scroll_top = 0;
}
}
}
/// A generic representation of a display row for selection popups and menus.
pub(crate) struct GenericDisplayRow {
pub name: String,
pub match_indices: Option<Vec<usize>>,
pub is_current: bool,
pub description: Option<String>,
}
impl GenericDisplayRow {}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups and menus.
pub(crate) fn render_rows(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
_dim_non_selected: bool,
empty_message: &str,
) {
let mut rows: Vec<Row> = Vec::new();
if rows_all.is_empty() {
rows.push(Row::new(vec![Cell::from(Line::from(
empty_message.dim().italic(),
))]));
} else {
let max_rows_from_area = area.height as usize;
let visible_rows = max_results
.min(rows_all.len())
.min(max_rows_from_area.max(1));
// Compute starting index based on scroll state and selection.
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if sel > bottom {
start_idx = sel + 1 - visible_rows;
}
}
}
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_rows)
{
let GenericDisplayRow {
name,
match_indices,
is_current: _is_current,
description,
} = row;
// Highlight fuzzy indices when present.
let mut spans: Vec<Span> = Vec::with_capacity(name.len());
if let Some(idxs) = match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in name.chars().enumerate() {
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
spans.push(ch.to_string().bold());
} else {
spans.push(ch.to_string().into());
}
}
} else {
spans.push(name.clone().into());
}
if let Some(desc) = description.as_ref() {
spans.push(" ".into());
spans.push(desc.clone().dim());
}
let mut cell = Cell::from(Line::from(spans));
if Some(i) == state.selected_idx {
cell = cell.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
}
rows.push(Row::new(vec![cell]));
}
}
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().add_modifier(Modifier::DIM)),
)
.widths([Constraint::Percentage(100)]);
table.render(area, buf);
}
impl ListSelectionView {
fn dim_prefix_span() -> Span<'static> {
"".dim()
@@ -245,3 +436,33 @@ impl BottomPaneView for ListSelectionView {
}
}
}
#[cfg(test)]
mod tests {
use super::ScrollState;
#[test]
fn wrap_navigation_and_visibility() {
let mut s = ScrollState::new();
let len = 10;
let vis = 5;
s.clamp_selection(len);
assert_eq!(s.selected_idx, Some(0));
s.ensure_visible(len, vis);
assert_eq!(s.scroll_top, 0);
s.move_up_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(len - 1));
match s.selected_idx {
Some(sel) => assert!(s.scroll_top <= sel),
None => panic!("expected Some(selected_idx) after wrap"),
}
s.move_down_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(0));
assert_eq!(s.scroll_top, 0);
}
}

View File

@@ -21,11 +21,9 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod list_selection_view;
pub(crate) mod list_selection_view;
mod paste_burst;
mod popup_consts;
mod scroll_state;
mod selection_popup_common;
mod textarea;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]

View File

@@ -1,115 +0,0 @@
/// Generic scroll/selection state for a vertical list menu.
///
/// Encapsulates the common behavior of a selectable list that supports:
/// - Optional selection (None when list is empty)
/// - Wrap-around navigation on Up/Down
/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ScrollState {
pub selected_idx: Option<usize>,
pub scroll_top: usize,
}
impl ScrollState {
pub fn new() -> Self {
Self {
selected_idx: None,
scroll_top: 0,
}
}
/// Reset selection and scroll.
pub fn reset(&mut self) {
self.selected_idx = None;
self.scroll_top = 0;
}
/// Clamp selection to be within the [0, len-1] range, or None when empty.
pub fn clamp_selection(&mut self, len: usize) {
self.selected_idx = match len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(len - 1)),
};
if len == 0 {
self.scroll_top = 0;
}
}
/// Move selection up by one, wrapping to the bottom when necessary.
pub fn move_up_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx > 0 => idx - 1,
Some(_) => len - 1,
None => 0,
});
}
/// Move selection down by one, wrapping to the top when necessary.
pub fn move_down_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx + 1 < len => idx + 1,
_ => 0,
});
}
/// Adjust `scroll_top` so that the current `selected_idx` is visible within
/// the window of `visible_rows`.
pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
if len == 0 || visible_rows == 0 {
self.scroll_top = 0;
return;
}
if let Some(sel) = self.selected_idx {
if sel < self.scroll_top {
self.scroll_top = sel;
} else {
let bottom = self.scroll_top + visible_rows - 1;
if sel > bottom {
self.scroll_top = sel + 1 - visible_rows;
}
}
} else {
self.scroll_top = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::ScrollState;
#[test]
fn wrap_navigation_and_visibility() {
let mut s = ScrollState::new();
let len = 10;
let vis = 5;
s.clamp_selection(len);
assert_eq!(s.selected_idx, Some(0));
s.ensure_visible(len, vis);
assert_eq!(s.scroll_top, 0);
s.move_up_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(len - 1));
match s.selected_idx {
Some(sel) => assert!(s.scroll_top <= sel),
None => panic!("expected Some(selected_idx) after wrap"),
}
s.move_down_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(0));
assert_eq!(s.scroll_top, 0);
}
}

View File

@@ -1,121 +0,0 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Constraint;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use super::scroll_state::ScrollState;
/// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow {
pub name: String,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub is_current: bool,
pub description: Option<String>, // optional grey text after the name
}
impl GenericDisplayRow {}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
pub(crate) fn render_rows(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
_dim_non_selected: bool,
empty_message: &str,
) {
let mut rows: Vec<Row> = Vec::new();
if rows_all.is_empty() {
rows.push(Row::new(vec![Cell::from(Line::from(
empty_message.dim().italic(),
))]));
} else {
let max_rows_from_area = area.height as usize;
let visible_rows = max_results
.min(rows_all.len())
.min(max_rows_from_area.max(1));
// Compute starting index based on scroll state and selection.
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if sel > bottom {
start_idx = sel + 1 - visible_rows;
}
}
}
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_rows)
{
let GenericDisplayRow {
name,
match_indices,
is_current: _is_current,
description,
} = row;
// Highlight fuzzy indices when present.
let mut spans: Vec<Span> = Vec::with_capacity(name.len());
if let Some(idxs) = match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in name.chars().enumerate() {
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
spans.push(ch.to_string().bold());
} else {
spans.push(ch.to_string().into());
}
}
} else {
spans.push(name.clone().into());
}
if let Some(desc) = description.as_ref() {
spans.push(" ".into());
spans.push(desc.clone().dim());
}
let mut cell = Cell::from(Line::from(spans));
if Some(i) == state.selected_idx {
cell = cell.style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
}
rows.push(Row::new(vec![cell]));
}
}
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().add_modifier(Modifier::DIM)),
)
.widths([Constraint::Percentage(100)]);
table.render(area, buf);
}

View File

@@ -1,12 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 728
expression: terminal.backend()
---
" "
"this is a test reason such as one that would be produced by the model "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"this is a test reason such as one that would be produced by the model "
" "
"▌ Allow command? "
"▌[Y] Yes Approve and run the command "
"▌[A] Always Approve the command for the remainder of this session "
"▌[N] No, provide feedback Do not run the command; provide feedback "
" "

View File

@@ -3,7 +3,9 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"▌ "
"▌ Allow command? "
"▌[Y] Yes Approve and run the command "
"▌[A] Always Approve the command for the remainder of this session "
"▌[N] No, provide feedback Do not run the command; provide feedback "
" "

View File

@@ -1,14 +1,13 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 794
expression: terminal.backend()
---
" "
"The model wants to apply changes "
" "
"This will grant write access to /tmp for the remainder of this session. "
" "
"▌Apply changes? "
"▌ Yes No, provide feedback "
"▌ Approve and apply the changes "
"The model wants to apply changes "
" "
"This will grant write access to /tmp for the remainder of this session. "
" "
"▌ Apply changes? "
"▌[Y] Yes Approve and apply the changes "
"▌[N] No, provide feedback Do not apply the changes; provide feedback "
" "

View File

@@ -1,12 +1,12 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 921
expression: terminal.backend()
---
" "
"this is a test reason such as one that would be produced by the model "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"this is a test reason such as one that would be produced by the model "
" "
"▌ Allow command? "
"▌[Y] Yes Approve and run the command "
"▌[A] Always Approve the command for the remainder of this session "
"▌[N] No, provide feedback Do not run the command; provide feedback "
" "

View File

@@ -18,9 +18,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
@@ -28,8 +26,12 @@ use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::list_selection_view::GenericDisplayRow;
use crate::bottom_pane::list_selection_view::ScrollState;
use crate::bottom_pane::list_selection_view::render_rows;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::render::line_utils::prefix_lines;
use crate::text_formatting::truncate_text;
/// Request coming from the agent that needs user approval.
@@ -50,7 +52,7 @@ pub(crate) enum ApprovalRequest {
///
/// The `key` is matched case-insensitively.
struct SelectOption {
label: Line<'static>,
label: &'static str,
description: &'static str,
key: KeyCode,
decision: ReviewDecision,
@@ -59,19 +61,19 @@ struct SelectOption {
static COMMAND_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
vec![
SelectOption {
label: Line::from(vec!["Y".underlined(), "es".into()]),
label: "Yes",
description: "Approve and run the command",
key: KeyCode::Char('y'),
decision: ReviewDecision::Approved,
},
SelectOption {
label: Line::from(vec!["A".underlined(), "lways".into()]),
label: "Always",
description: "Approve the command for the remainder of this session",
key: KeyCode::Char('a'),
decision: ReviewDecision::ApprovedForSession,
},
SelectOption {
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
label: "No, provide feedback",
description: "Do not run the command; provide feedback",
key: KeyCode::Char('n'),
decision: ReviewDecision::Abort,
@@ -82,13 +84,13 @@ static COMMAND_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
vec![
SelectOption {
label: Line::from(vec!["Y".underlined(), "es".into()]),
label: "Yes",
description: "Approve and apply the changes",
key: KeyCode::Char('y'),
decision: ReviewDecision::Approved,
},
SelectOption {
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
label: "No, provide feedback",
description: "Do not apply the changes; provide feedback",
key: KeyCode::Char('n'),
decision: ReviewDecision::Abort,
@@ -102,9 +104,7 @@ pub(crate) struct UserApprovalWidget {
app_event_tx: AppEventSender,
confirmation_prompt: Paragraph<'static>,
select_options: &'static Vec<SelectOption>,
/// Currently selected index in *select* mode.
selected_option: usize,
scroll_state: ScrollState,
/// Set to `true` once a decision has been sent the parent view can then
/// remove this widget from its queue.
@@ -115,17 +115,21 @@ impl UserApprovalWidget {
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let confirmation_prompt = match &approval_request {
ApprovalRequest::Exec { reason, .. } => {
let mut contents: Vec<Line> = vec![];
let mut contents: Vec<Line<'static>> = vec![];
if let Some(reason) = reason {
contents.push(Line::from(reason.clone().italic()));
contents.push(Line::from(""));
}
Paragraph::new(contents).wrap(Wrap { trim: false })
if contents.is_empty() {
contents.push(Line::from(""));
}
let prefixed = prefix_lines(contents, "".dim(), "".dim());
Paragraph::new(prefixed).wrap(Wrap { trim: false })
}
ApprovalRequest::ApplyPatch {
reason, grant_root, ..
} => {
let mut contents: Vec<Line> = vec![];
let mut contents: Vec<Line<'static>> = vec![];
if let Some(r) = reason {
contents.push(Line::from(r.clone().italic()));
@@ -140,19 +144,31 @@ impl UserApprovalWidget {
contents.push(Line::from(""));
}
Paragraph::new(contents).wrap(Wrap { trim: false })
if contents.is_empty() {
contents.push(Line::from(""));
}
let prefixed = prefix_lines(contents, "".dim(), "".dim());
Paragraph::new(prefixed).wrap(Wrap { trim: false })
}
};
let select_options = match &approval_request {
ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS,
ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS,
};
let mut scroll_state = ScrollState::new();
let len = select_options.len();
scroll_state.clamp_selection(len);
scroll_state.ensure_visible(len, len);
Self {
select_options: match &approval_request {
ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS,
ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS,
},
select_options,
approval_request,
app_event_tx,
confirmation_prompt,
selected_option: 0,
scroll_state,
done: false,
}
}
@@ -183,6 +199,24 @@ impl UserApprovalWidget {
}
}
fn selected_option(&self) -> Option<&SelectOption> {
self.scroll_state
.selected_idx
.and_then(|idx| self.select_options.get(idx))
}
fn format_option_label(option: &SelectOption) -> String {
match option.key {
KeyCode::Char(c) => format!("[{}] {}", c.to_ascii_uppercase(), option.label),
KeyCode::Enter => format!("[Enter] {}", option.label),
KeyCode::Esc => format!("[Esc] {}", option.label),
KeyCode::Tab => format!("[Tab] {}", option.label),
KeyCode::BackTab => format!("[Shift-Tab] {}", option.label),
KeyCode::F(n) => format!("[F{n}] {}", option.label),
_ => option.label.to_string(),
}
}
/// Handle Ctrl-C pressed by the user while the modal is visible.
/// Behaves like pressing Escape: abort the request and close the modal.
pub(crate) fn on_ctrl_c(&mut self) {
@@ -190,28 +224,38 @@ impl UserApprovalWidget {
}
fn handle_select_key(&mut self, key_event: KeyEvent) {
let len = self.select_options.len();
if len == 0 {
return;
}
match key_event.code {
KeyCode::Left => {
self.selected_option = (self.selected_option + self.select_options.len() - 1)
% self.select_options.len();
KeyCode::Up | KeyCode::Char('k') | KeyCode::Left | KeyCode::Char('h') => {
self.scroll_state.move_up_wrap(len);
self.scroll_state.ensure_visible(len, len);
}
KeyCode::Right => {
self.selected_option = (self.selected_option + 1) % self.select_options.len();
KeyCode::Down | KeyCode::Char('j') | KeyCode::Right | KeyCode::Char('l') => {
self.scroll_state.move_down_wrap(len);
self.scroll_state.ensure_visible(len, len);
}
KeyCode::Enter => {
let opt = &self.select_options[self.selected_option];
self.send_decision(opt.decision);
if let Some(opt) = self.selected_option() {
self.send_decision(opt.decision);
}
}
KeyCode::Esc => {
self.send_decision(ReviewDecision::Abort);
}
other => {
let normalized = Self::normalize_keycode(other);
if let Some(opt) = self
if let Some((idx, opt)) = self
.select_options
.iter()
.find(|opt| Self::normalize_keycode(opt.key) == normalized)
.enumerate()
.find(|(_, opt)| Self::normalize_keycode(opt.key) == normalized)
{
self.scroll_state.selected_idx = Some(idx);
self.scroll_state.ensure_visible(len, len);
self.send_decision(opt.decision);
}
}
@@ -318,11 +362,11 @@ impl UserApprovalWidget {
}
pub(crate) fn desired_height(&self, width: u16) -> u16 {
// Reserve space for:
// - 1 title line ("Allow command?" or "Apply changes?")
// - 1 buttons line (options rendered horizontally on a single row)
// - 1 description line (context for the currently selected option)
self.get_confirmation_prompt_height(width) + 3
let prompt_height = self.get_confirmation_prompt_height(width);
let option_rows = self.select_options.len().max(1) as u16;
prompt_height
.saturating_add(1) // title line
.saturating_add(option_rows)
}
}
@@ -334,57 +378,39 @@ impl WidgetRef for &UserApprovalWidget {
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
.areas(area);
let lines: Vec<Line> = self
.select_options
.iter()
.enumerate()
.map(|(idx, opt)| {
let style = if idx == self.selected_option {
Style::new().bg(Color::Cyan).fg(Color::Black)
} else {
Style::new().add_modifier(Modifier::DIM)
};
opt.label.clone().alignment(Alignment::Center).style(style)
})
.collect();
let [title_area, button_area, description_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(response_chunk.inner(Margin::new(1, 0)));
let [title_area, options_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(response_chunk);
let title = match &self.approval_request {
ApprovalRequest::Exec { .. } => "Allow command?",
ApprovalRequest::ApplyPatch { .. } => "Apply changes?",
};
Line::from(title).render(title_area, buf);
let title_line = Line::from(vec!["".dim(), title.to_string().bold()]);
Paragraph::new(title_line).render(title_area, buf);
self.confirmation_prompt.clone().render(prompt_chunk, buf);
let areas = Layout::horizontal(
lines
.iter()
.map(|l| Constraint::Length(l.width() as u16 + 2)),
)
.spacing(1)
.split(button_area);
for (idx, area) in areas.iter().enumerate() {
let line = &lines[idx];
line.render(*area, buf);
}
Line::from(self.select_options[self.selected_option].description)
.style(Style::new().italic().add_modifier(Modifier::DIM))
.render(description_area.inner(Margin::new(1, 0)), buf);
let rows: Vec<GenericDisplayRow> = self
.select_options
.iter()
.map(|opt| GenericDisplayRow {
name: UserApprovalWidget::format_option_label(opt),
match_indices: None,
is_current: false,
description: Some(opt.description.to_string()),
})
.collect();
Block::bordered()
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(Color::Cyan))
.borders(Borders::LEFT)
.render_ref(
Rect::new(0, response_chunk.y, 1, response_chunk.height),
if options_area.height > 0 {
render_rows(
options_area,
buf,
&rows,
&self.scroll_state,
self.select_options.len(),
false,
"no options",
);
}
}
}