mirror of
https://github.com/openai/codex.git
synced 2026-03-03 21:23:18 +00:00
Compare commits
2 Commits
fix/notify
...
codex/make
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff835cca5 | ||
|
|
01cf1f7f65 |
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user