diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs deleted file mode 100644 index 912df6ce..00000000 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ /dev/null @@ -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, - 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 { - 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::(); - 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::(); - // 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()); - } -} diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs new file mode 100644 index 00000000..7ab2b79a --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -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, + reason: Option, + }, + ApplyPatch { + id: String, + reason: Option, + grant_root: Option, + }, +} + +/// Modal overlay asking the user to approve or deny one or more requests. +pub(crate) struct ApprovalOverlay { + current: Option, + queue: Vec, + app_event_tx: AppEventSender, + list: ListSelectionView, + options: Vec, + 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, 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 { + 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, +} + +impl From 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 }, + ApplyPatch { id: String }, +} + +#[derive(Clone)] +struct ApprovalOption { + label: String, + description: String, + decision: ReviewDecision, + shortcut: Option, +} + +fn exec_options() -> Vec { + 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 { + 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, + decision: ReviewDecision, +) -> Option>> { + use ReviewDecision::*; + + let (symbol, summary): (Span<'static>, Vec>) = 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::(); + 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::(); + 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::(); + 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 = (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::(); + 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)); + } +} diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 6bc436f9..8f569ec8 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -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; diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index a856a3c6..c95f1ee0 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -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; +#[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, @@ -39,6 +46,7 @@ pub(crate) struct SelectionViewParams { pub items: Vec, pub is_searchable: bool, pub search_placeholder: Option, + pub header: Vec, } pub(crate) struct ListSelectionView { @@ -53,6 +61,8 @@ pub(crate) struct ListSelectionView { search_query: String, search_placeholder: Option, filtered_indices: Vec, + last_selected_actual_idx: Option, + header: Vec, } 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 { + self.last_selected_actual_idx.take() + } + + fn header_spans_for_width(&self, width: u16) -> Vec>> { + 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> = 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> = - 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> = - 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); } } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 61a2378a..062ccdbb 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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)); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2a9eaab3..4678dd24 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index 60878539..304adf54 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -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 " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index fb1ecce7..239681ac 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -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 " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 183a2950..2ee14a57 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -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 " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 60878539..e762896f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -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 " " " diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1453971c..8f51bc9d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -66,7 +66,6 @@ mod streaming; mod text_formatting; mod tui; mod ui_consts; -mod user_approval_widget; mod version; mod wrapping; diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs deleted file mode 100644 index 410766e0..00000000 --- a/codex-rs/tui/src/user_approval_widget.rs +++ /dev/null @@ -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 feature‑parity for the keyboard -//! driven workflow – a fully‑fledged 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, - reason: Option, - }, - ApplyPatch { - id: String, - reason: Option, - grant_root: Option, - }, -} - -/// 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> = 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> = 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, - - /// 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 = 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 = 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 don’t 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> = 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> = 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 = 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::(); - 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 = 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::(); - 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 = 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, - .. - }) - ))); - } -}