Move approvals to use ListSelectionView (#4275)

Unify selection menus:
- Move approvals to the vertical menu `ListSelectionView`
- Add header section to `ListSelectionView`

<img width="502" height="214" alt="image"
src="https://github.com/user-attachments/assets/f4b43ddf-3549-403c-ad9e-a523688714e4"
/>

<img width="748" height="214" alt="image"
src="https://github.com/user-attachments/assets/f94ac7b5-dc94-4dc0-a1df-7a8e3ba2453b"
/>

---------

Co-authored-by: pakrym-oai <pakrym@openai.com>
This commit is contained in:
Ahmed Ibrahim
2025-09-26 07:13:29 -07:00
committed by GitHub
parent 02609184be
commit 41f5d61f24
12 changed files with 705 additions and 598 deletions

View File

@@ -1,111 +0,0 @@
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use crate::app_event_sender::AppEventSender;
use crate::user_approval_widget::ApprovalRequest;
use crate::user_approval_widget::UserApprovalWidget;
use super::BottomPaneView;
use super::CancellationEvent;
/// Modal overlay asking the user to approve/deny a sequence of requests.
pub(crate) struct ApprovalModalView {
current: UserApprovalWidget,
queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender,
}
impl ApprovalModalView {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
Self {
current: UserApprovalWidget::new(request, app_event_tx.clone()),
queue: Vec::new(),
app_event_tx,
}
}
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
self.queue.push(req);
}
/// Advance to next request if the current one is finished.
fn maybe_advance(&mut self) {
if self.current.is_complete()
&& let Some(req) = self.queue.pop()
{
self.current = UserApprovalWidget::new(req, self.app_event_tx.clone());
}
}
}
impl BottomPaneView for ApprovalModalView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
self.current.handle_key_event(key_event);
self.maybe_advance();
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.current.on_ctrl_c();
self.queue.clear();
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.current.is_complete() && self.queue.is_empty()
}
fn desired_height(&self, width: u16) -> u16 {
self.current.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
(&self.current).render_ref(area, buf);
}
fn try_consume_approval_request(&mut self, req: ApprovalRequest) -> Option<ApprovalRequest> {
self.enqueue_request(req);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::bottom_pane::BottomPane;
use tokio::sync::mpsc::unbounded_channel;
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: None,
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let first = make_exec_request();
let mut view = ApprovalModalView::new(first, tx);
view.enqueue_request(make_exec_request());
let (tx2, _rx2) = unbounded_channel::<AppEvent>();
// Why do we have this?
let _pane = BottomPane::new(super::super::BottomPaneParams {
app_event_tx: AppEventSender::new(tx2),
frame_requester: crate::tui::FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
});
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
assert!(view.queue.is_empty());
assert!(view.current.is_complete());
assert!(view.is_complete());
}
}

View File

@@ -0,0 +1,559 @@
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::list_selection_view::HeaderLine;
use crate::bottom_pane::list_selection_view::ListSelectionView;
use crate::bottom_pane::list_selection_view::SelectionItem;
use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::text_formatting::truncate_text;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
Exec {
id: String,
command: Vec<String>,
reason: Option<String>,
},
ApplyPatch {
id: String,
reason: Option<String>,
grant_root: Option<PathBuf>,
},
}
/// Modal overlay asking the user to approve or deny one or more requests.
pub(crate) struct ApprovalOverlay {
current: Option<ApprovalRequestState>,
queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender,
list: ListSelectionView,
options: Vec<ApprovalOption>,
current_complete: bool,
done: bool,
}
impl ApprovalOverlay {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let mut view = Self {
current: Some(ApprovalRequestState::from(request)),
queue: Vec::new(),
app_event_tx: app_event_tx.clone(),
list: ListSelectionView::new(
SelectionViewParams {
title: String::new(),
..Default::default()
},
app_event_tx,
),
options: Vec::new(),
current_complete: false,
done: false,
};
let (options, params) = view.build_options();
view.options = options;
view.list = ListSelectionView::new(params, view.app_event_tx.clone());
view
}
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
self.queue.push(req);
}
fn set_current(&mut self, request: ApprovalRequest) {
self.current = Some(ApprovalRequestState::from(request));
self.current_complete = false;
let (options, params) = self.build_options();
self.options = options;
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
}
fn build_options(&self) -> (Vec<ApprovalOption>, SelectionViewParams) {
let Some(state) = self.current.as_ref() else {
return (
Vec::new(),
SelectionViewParams {
title: String::new(),
..Default::default()
},
);
};
let (options, title) = match &state.variant {
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
};
let items = options
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
description: Some(opt.description.clone()),
is_current: false,
actions: Vec::new(),
dismiss_on_select: false,
search_value: None,
})
.collect();
let params = SelectionViewParams {
title,
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
items,
header: state.header.clone(),
..Default::default()
};
(options, params)
}
fn apply_selection(&mut self, actual_idx: usize) {
if self.current_complete {
return;
}
let Some(option) = self.options.get(actual_idx) else {
return;
};
if let Some(state) = self.current.as_ref() {
match (&state.variant, option.decision) {
(ApprovalVariant::Exec { id, command }, decision) => {
self.handle_exec_decision(id, command, decision);
}
(ApprovalVariant::ApplyPatch { id, .. }, decision) => {
self.handle_patch_decision(id, decision);
}
}
}
self.current_complete = true;
self.advance_queue();
}
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
if let Some(lines) = build_exec_history_lines(command.to_vec(), decision) {
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_user_approval_decision(lines),
)));
}
self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval {
id: id.to_string(),
decision,
}));
}
fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) {
self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval {
id: id.to_string(),
decision,
}));
}
fn advance_queue(&mut self) {
if let Some(next) = self.queue.pop() {
self.set_current(next);
} else {
self.done = true;
}
}
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press {
return false;
}
let KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} = key_event
else {
return false;
};
if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) {
return false;
}
let lower = c.to_ascii_lowercase();
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
{
self.apply_selection(idx);
true
} else {
false
}
}
}
impl BottomPaneView for ApprovalOverlay {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.try_handle_shortcut(&key_event) {
return;
}
self.list.handle_key_event(key_event);
if let Some(idx) = self.list.take_last_selected_index() {
self.apply_selection(idx);
}
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.done {
return CancellationEvent::Handled;
}
if !self.current_complete
&& let Some(state) = self.current.as_ref()
{
match &state.variant {
ApprovalVariant::Exec { id, command } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
}
ApprovalVariant::ApplyPatch { id, .. } => {
self.handle_patch_decision(id, ReviewDecision::Abort);
}
}
}
self.queue.clear();
self.done = true;
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.done
}
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
fn try_consume_approval_request(
&mut self,
request: ApprovalRequest,
) -> Option<ApprovalRequest> {
self.enqueue_request(request);
None
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.list.cursor_pos(area)
}
}
struct ApprovalRequestState {
variant: ApprovalVariant,
header: Vec<HeaderLine>,
}
impl From<ApprovalRequest> for ApprovalRequestState {
fn from(value: ApprovalRequest) -> Self {
match value {
ApprovalRequest::Exec {
id,
command,
reason,
} => {
let mut header = Vec::new();
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(HeaderLine::Text {
text: reason,
italic: true,
});
header.push(HeaderLine::Spacer);
}
let command_snippet = exec_snippet(&command);
if !command_snippet.is_empty() {
header.push(HeaderLine::Text {
text: format!("Command: {command_snippet}"),
italic: false,
});
header.push(HeaderLine::Spacer);
}
Self {
variant: ApprovalVariant::Exec { id, command },
header,
}
}
ApprovalRequest::ApplyPatch {
id,
reason,
grant_root,
} => {
let mut header = Vec::new();
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(HeaderLine::Text {
text: reason,
italic: true,
});
header.push(HeaderLine::Spacer);
}
if let Some(root) = grant_root {
header.push(HeaderLine::Text {
text: format!(
"Grant write access to {} for the remainder of this session.",
root.display()
),
italic: false,
});
header.push(HeaderLine::Spacer);
}
Self {
variant: ApprovalVariant::ApplyPatch { id },
header,
}
}
}
}
}
enum ApprovalVariant {
Exec { id: String, command: Vec<String> },
ApplyPatch { id: String },
}
#[derive(Clone)]
struct ApprovalOption {
label: String,
description: String,
decision: ReviewDecision,
shortcut: Option<char>,
}
fn exec_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve and run now".to_string(),
description: "(Y) Run this command one time".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
},
ApprovalOption {
label: "Always approve this session".to_string(),
description: "(A) Automatically approve this command for the rest of the session"
.to_string(),
decision: ReviewDecision::ApprovedForSession,
shortcut: Some('a'),
},
ApprovalOption {
label: "Cancel".to_string(),
description: "(N) Do not run the command".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
},
]
}
fn patch_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve".to_string(),
description: "(Y) Apply the proposed changes".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
},
ApprovalOption {
label: "Cancel".to_string(),
description: "(N) Do not apply the changes".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
},
]
}
fn build_exec_history_lines(
command: Vec<String>,
decision: ReviewDecision,
) -> Option<Vec<Line<'static>>> {
use ReviewDecision::*;
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
Approved => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" this time".bold(),
],
)
}
ApprovedForSession => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" every time this session".bold(),
],
)
}
Denied => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".red(),
vec![
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
snippet,
],
)
}
Abort => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".red(),
vec![
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
snippet,
],
)
}
};
let mut lines = Vec::new();
let mut spans = Vec::new();
spans.push(symbol);
spans.extend(summary);
lines.push(Line::from(spans));
Some(lines)
}
fn truncate_exec_snippet(full_cmd: &str) -> String {
let mut snippet = match full_cmd.split_once('\n') {
Some((first, _)) => format!("{first} ..."),
None => full_cmd.to_string(),
};
snippet = truncate_text(&snippet, 80);
snippet
}
fn exec_snippet(command: &[String]) -> String {
let full_cmd = strip_bash_lc_and_escape(command);
truncate_exec_snippet(&full_cmd)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use tokio::sync::mpsc::unbounded_channel;
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
reason: Some("reason".to_string()),
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
view.enqueue_request(make_exec_request());
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
assert!(view.queue.is_empty());
assert!(view.is_complete());
}
#[test]
fn shortcut_triggers_selection() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
assert!(!view.is_complete());
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
// We expect at least one CodexOp message in the queue.
let mut saw_op = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, AppEvent::CodexOp(_)) {
saw_op = true;
break;
}
}
assert!(saw_op, "expected approval decision to emit an op");
}
#[test]
fn header_includes_command_snippet() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let command = vec!["echo".into(), "hello".into(), "world".into()];
let exec_request = ApprovalRequest::Exec {
id: "test".into(),
command,
reason: None,
};
let view = ApprovalOverlay::new(exec_request, tx);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6));
view.render(Rect::new(0, 0, 80, 6), &mut buf);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
(0..buf.area.width)
.map(|col| buf[(col, row)].symbol().to_string())
.collect()
})
.collect();
assert!(
rendered
.iter()
.any(|line| line.contains("Command: echo hello world")),
"expected header to include command snippet, got {rendered:?}"
);
}
#[test]
fn enter_sets_last_selected_index_without_dismissing() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
view.is_complete(),
"exec approval should complete without queued requests"
);
let mut decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ExecApproval { decision: d, .. }) = ev {
decision = Some(d);
break;
}
}
assert_eq!(decision, Some(ReviewDecision::ApprovedForSession));
}
}

View File

@@ -1,4 +1,4 @@
use crate::user_approval_widget::ApprovalRequest;
use crate::bottom_pane::ApprovalRequest;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;

View File

@@ -8,6 +8,7 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use textwrap::wrap;
use crate::app_event_sender::AppEventSender;
@@ -22,6 +23,12 @@ use super::selection_popup_common::render_rows;
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HeaderLine {
Text { text: String, italic: bool },
Spacer,
}
pub(crate) struct SelectionItem {
pub name: String,
pub description: Option<String>,
@@ -39,6 +46,7 @@ pub(crate) struct SelectionViewParams {
pub items: Vec<SelectionItem>,
pub is_searchable: bool,
pub search_placeholder: Option<String>,
pub header: Vec<HeaderLine>,
}
pub(crate) struct ListSelectionView {
@@ -53,6 +61,8 @@ pub(crate) struct ListSelectionView {
search_query: String,
search_placeholder: Option<String>,
filtered_indices: Vec<usize>,
last_selected_actual_idx: Option<usize>,
header: Vec<HeaderLine>,
}
impl ListSelectionView {
@@ -82,6 +92,8 @@ impl ListSelectionView {
None
},
filtered_indices: Vec::new(),
last_selected_actual_idx: None,
header: params.header,
};
s.apply_filter();
s
@@ -198,6 +210,7 @@ impl ListSelectionView {
&& let Some(actual_idx) = self.filtered_indices.get(idx)
&& let Some(item) = self.items.get(*actual_idx)
{
self.last_selected_actual_idx = Some(*actual_idx);
for act in &item.actions {
act(&self.app_event_tx);
}
@@ -214,6 +227,43 @@ impl ListSelectionView {
self.search_query = query;
self.apply_filter();
}
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
self.last_selected_actual_idx.take()
}
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
if self.header.is_empty() || width == 0 {
return Vec::new();
}
let prefix_width = Self::dim_prefix_span().width() as u16;
let available = width.saturating_sub(prefix_width).max(1) as usize;
let mut lines = Vec::new();
for entry in &self.header {
match entry {
HeaderLine::Spacer => lines.push(Vec::new()),
HeaderLine::Text { text, italic } => {
if text.is_empty() {
lines.push(Vec::new());
continue;
}
for part in wrap(text, available) {
let span = if *italic {
Span::from(part.into_owned()).italic()
} else {
Span::from(part.into_owned())
};
lines.push(vec![span]);
}
}
}
}
lines
}
fn header_height(&self, width: u16) -> u16 {
self.header_spans_for_width(width).len() as u16
}
}
impl BottomPaneView for ListSelectionView {
@@ -276,7 +326,8 @@ 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;
let mut height = self.header_height(width);
height = height.saturating_add(rows_height + 2);
if self.is_searchable {
height = height.saturating_add(1);
}
@@ -295,20 +346,46 @@ impl BottomPaneView for ListSelectionView {
return;
}
let mut next_y = area.y;
let header_spans = self.header_spans_for_width(area.width);
for spans in header_spans.into_iter() {
if next_y >= area.y + area.height {
return;
}
let row = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
if spans.is_empty() {
prefixed.push(String::new().into());
} else {
prefixed.extend(spans);
}
Paragraph::new(Line::from(prefixed)).render(row, buf);
next_y = next_y.saturating_add(1);
}
if next_y >= area.y + area.height {
return;
}
let title_area = Rect {
x: area.x,
y: area.y,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![
Self::dim_prefix_span(),
self.title.clone().bold(),
]))
.render(title_area, buf);
next_y = next_y.saturating_add(1);
let title_spans: Vec<Span<'static>> =
vec![Self::dim_prefix_span(), self.title.clone().bold()];
let title_para = Paragraph::new(Line::from(title_spans));
title_para.render(title_area, buf);
let mut next_y = area.y.saturating_add(1);
if self.is_searchable {
if self.is_searchable && next_y < area.y + area.height {
let search_area = Rect {
x: area.x,
y: next_y,
@@ -327,20 +404,25 @@ impl BottomPaneView for ListSelectionView {
.render(search_area, buf);
next_y = next_y.saturating_add(1);
}
if let Some(sub) = &self.subtitle {
if next_y >= area.y + area.height {
return;
}
let subtitle_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let subtitle_spans: Vec<Span<'static>> =
vec![Self::dim_prefix_span(), sub.clone().dim()];
let subtitle_para = Paragraph::new(Line::from(subtitle_spans));
subtitle_para.render(subtitle_area, buf);
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
.render(subtitle_area, buf);
next_y = next_y.saturating_add(1);
}
if next_y >= area.y + area.height {
return;
}
let spacer_area = Rect {
x: area.x,
y: next_y,
@@ -351,6 +433,9 @@ impl BottomPaneView for ListSelectionView {
next_y = next_y.saturating_add(1);
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
if next_y >= area.y + area.height {
return;
}
let rows_area = Rect {
x: area.x,
y: next_y,
@@ -381,8 +466,7 @@ impl BottomPaneView for ListSelectionView {
width: area.width,
height: 1,
};
let footer_para = Paragraph::new(hint.clone().dim());
footer_para.render(footer_area, buf);
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
}
}
}

View File

@@ -3,7 +3,6 @@ use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::tui::FrameRequester;
use crate::user_approval_widget::ApprovalRequest;
use bottom_pane_view::BottomPaneView;
use codex_core::protocol::TokenUsageInfo;
use codex_file_search::FileMatch;
@@ -16,7 +15,9 @@ use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use std::time::Duration;
mod approval_modal_view;
mod approval_overlay;
pub(crate) use approval_overlay::ApprovalOverlay;
pub(crate) use approval_overlay::ApprovalRequest;
mod bottom_pane_view;
mod chat_composer;
mod chat_composer_history;
@@ -43,7 +44,6 @@ pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusIndicatorWidget;
use approval_modal_view::ApprovalModalView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
@@ -397,7 +397,7 @@ impl BottomPane {
};
// Otherwise create a new approval modal overlay.
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone());
self.pause_status_timer_for_modal();
self.push_view(Box::new(modal));
}

View File

@@ -84,7 +84,7 @@ use crate::slash_command::SlashCommand;
use crate::text_formatting::truncate_text;
use crate::tui::FrameRequester;
// streaming internals are provided by crate::streaming and crate::markdown_stream
use crate::user_approval_widget::ApprovalRequest;
use crate::bottom_pane::ApprovalRequest;
mod interrupts;
use self::interrupts::InterruptManager;
mod agent;

View File

@@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"this is a test reason such as one that would be produced by the model "
"this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -3,7 +3,14 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -3,11 +3,14 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"The model wants to apply changes "
"The model wants to apply changes "
"▌ "
"▌ Grant write access to /tmp for the remainder of this session. "
"▌ "
"▌ Apply changes? "
"▌ "
"▌ > 1. Approve (Y) Apply the proposed changes "
"▌ 2. Cancel (N) Do not apply the 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 "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"this is a test reason such as one that would be produced by the model "
"this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo 'hello world' "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"▌Allow command? "
"▌ Yes Always No, provide feedback "
"▌ Approve and run the command "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -66,7 +66,6 @@ mod streaming;
mod text_formatting;
mod tui;
mod ui_consts;
mod user_approval_widget;
mod version;
mod wrapping;

View File

@@ -1,448 +0,0 @@
//! A modal widget that prompts the user to approve or deny an action
//! requested by the agent.
//!
//! This is a (very) rough port of
//! `src/components/chat/terminal-chat-command-review.tsx` from the TypeScript
//! UI to Rust using [`ratatui`]. The goal is featureparity for the keyboard
//! driven workflow a fullyfledged visual match is not required.
use std::path::PathBuf;
use std::sync::LazyLock;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
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::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::text_formatting::truncate_text;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
Exec {
id: String,
command: Vec<String>,
reason: Option<String>,
},
ApplyPatch {
id: String,
reason: Option<String>,
grant_root: Option<PathBuf>,
},
}
/// Options displayed in the *select* mode.
///
/// The `key` is matched case-insensitively.
struct SelectOption {
label: Line<'static>,
description: &'static str,
key: KeyCode,
decision: ReviewDecision,
}
static COMMAND_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
vec![
SelectOption {
label: Line::from(vec!["Y".underlined(), "es".into()]),
description: "Approve and run the command",
key: KeyCode::Char('y'),
decision: ReviewDecision::Approved,
},
SelectOption {
label: Line::from(vec!["A".underlined(), "lways".into()]),
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()]),
description: "Do not run the command; provide feedback",
key: KeyCode::Char('n'),
decision: ReviewDecision::Abort,
},
]
});
static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
vec![
SelectOption {
label: Line::from(vec!["Y".underlined(), "es".into()]),
description: "Approve and apply the changes",
key: KeyCode::Char('y'),
decision: ReviewDecision::Approved,
},
SelectOption {
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
description: "Do not apply the changes; provide feedback",
key: KeyCode::Char('n'),
decision: ReviewDecision::Abort,
},
]
});
/// A modal prompting the user to approve or deny the pending request.
pub(crate) struct UserApprovalWidget {
approval_request: ApprovalRequest,
app_event_tx: AppEventSender,
confirmation_prompt: Paragraph<'static>,
select_options: &'static Vec<SelectOption>,
/// Currently selected index in *select* mode.
selected_option: usize,
/// Set to `true` once a decision has been sent the parent view can then
/// remove this widget from its queue.
done: bool,
}
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![];
if let Some(reason) = reason {
contents.push(Line::from(reason.clone().italic()));
contents.push(Line::from(""));
}
Paragraph::new(contents).wrap(Wrap { trim: false })
}
ApprovalRequest::ApplyPatch {
reason, grant_root, ..
} => {
let mut contents: Vec<Line> = vec![];
if let Some(r) = reason {
contents.push(Line::from(r.clone().italic()));
contents.push(Line::from(""));
}
if let Some(root) = grant_root {
contents.push(Line::from(format!(
"This will grant write access to {} for the remainder of this session.",
root.display()
)));
contents.push(Line::from(""));
}
Paragraph::new(contents).wrap(Wrap { trim: false })
}
};
Self {
select_options: match &approval_request {
ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS,
ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS,
},
approval_request,
app_event_tx,
confirmation_prompt,
selected_option: 0,
done: false,
}
}
fn get_confirmation_prompt_height(&self, width: u16) -> u16 {
// Should cache this for last value of width.
self.confirmation_prompt.line_count(width) as u16
}
/// Process a `KeyEvent` coming from crossterm. Always consumes the event
/// while the modal is visible.
/// Process a key event originating from crossterm. As the modal fully
/// captures input while visible, we dont need to report whether the event
/// was consumed—callers can assume it always is.
pub(crate) fn handle_key_event(&mut self, key: KeyEvent) {
if key.kind == KeyEventKind::Press {
self.handle_select_key(key);
}
}
/// Normalize a key for comparison.
/// - For `KeyCode::Char`, converts to lowercase for case-insensitive matching.
/// - Other key codes are returned unchanged.
fn normalize_keycode(code: KeyCode) -> KeyCode {
match code {
KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
other => other,
}
}
/// 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) {
self.send_decision(ReviewDecision::Abort);
}
fn handle_select_key(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Left => {
self.selected_option = (self.selected_option + self.select_options.len() - 1)
% self.select_options.len();
}
KeyCode::Right => {
self.selected_option = (self.selected_option + 1) % self.select_options.len();
}
KeyCode::Enter => {
let opt = &self.select_options[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
.select_options
.iter()
.find(|opt| Self::normalize_keycode(opt.key) == normalized)
{
self.send_decision(opt.decision);
}
}
}
}
fn send_decision(&mut self, decision: ReviewDecision) {
self.send_decision_with_feedback(decision, String::new())
}
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let full_cmd = strip_bash_lc_and_escape(command);
// Construct a concise, single-line summary of the command:
// - If multi-line, take the first line and append " ...".
// - Truncate to 80 graphemes.
let mut snippet = match full_cmd.split_once('\n') {
Some((first, _)) => format!("{first} ..."),
None => full_cmd.clone(),
};
// Enforce the 80 character length limit.
snippet = truncate_text(&snippet, 80);
let mut result_spans: Vec<Span<'static>> = Vec::new();
match decision {
ReviewDecision::Approved => {
result_spans.extend(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet.dim(),
" this time".bold(),
]);
}
ReviewDecision::ApprovedForSession => {
result_spans.extend(vec![
"".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet.dim(),
" every time this session".bold(),
]);
}
ReviewDecision::Denied => {
result_spans.extend(vec![
"".fg(Color::Red),
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
snippet.dim(),
]);
}
ReviewDecision::Abort => {
result_spans.extend(vec![
"".fg(Color::Red),
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
snippet.dim(),
]);
}
}
let mut lines: Vec<Line<'static>> = vec![Line::from(result_spans)];
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_user_approval_decision(lines),
)));
}
ApprovalRequest::ApplyPatch { .. } => {
// No history line for patch approval decisions.
}
}
let op = match &self.approval_request {
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
id: id.clone(),
decision,
},
ApprovalRequest::ApplyPatch { id, .. } => Op::PatchApproval {
id: id.clone(),
decision,
},
};
self.app_event_tx.send(AppEvent::CodexOp(op));
self.done = true;
}
/// Returns `true` once the user has made a decision and the widget no
/// longer needs to be displayed.
pub(crate) fn is_complete(&self) -> bool {
self.done
}
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
}
}
impl WidgetRef for &UserApprovalWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let prompt_height = self.get_confirmation_prompt_height(area.width);
let [prompt_chunk, response_chunk] = Layout::default()
.direction(Direction::Vertical)
.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 = match &self.approval_request {
ApprovalRequest::Exec { .. } => "Allow command?",
ApprovalRequest::ApplyPatch { .. } => "Apply changes?",
};
Line::from(title).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);
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),
buf,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use tokio::sync::mpsc::unbounded_channel;
#[test]
fn lowercase_shortcut_is_accepted() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let req = ApprovalRequest::Exec {
id: "1".to_string(),
command: vec!["echo".to_string()],
reason: None,
};
let mut widget = UserApprovalWidget::new(req, tx);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(widget.is_complete());
let mut events: Vec<AppEvent> = Vec::new();
while let Ok(ev) = rx.try_recv() {
events.push(ev);
}
assert!(events.iter().any(|e| matches!(
e,
AppEvent::CodexOp(Op::ExecApproval {
decision: ReviewDecision::Approved,
..
})
)));
}
#[test]
fn uppercase_shortcut_is_accepted() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let req = ApprovalRequest::Exec {
id: "2".to_string(),
command: vec!["echo".to_string()],
reason: None,
};
let mut widget = UserApprovalWidget::new(req, tx);
widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE));
assert!(widget.is_complete());
let mut events: Vec<AppEvent> = Vec::new();
while let Ok(ev) = rx.try_recv() {
events.push(ev);
}
assert!(events.iter().any(|e| matches!(
e,
AppEvent::CodexOp(Op::ExecApproval {
decision: ReviewDecision::Approved,
..
})
)));
}
}