Files
codex/prs/bolinfest/PR-1768.md
2025-09-02 15:17:45 -07:00

31 KiB
Raw Blame History

PR #1768: lighter approval modal

Description

The yellow hazard stripes were too scary :)

This also has the added benefit of not rendering anything at the full width of the terminal, so resizing is a little easier to handle.

Screenshot 2025-07-31 at 4 03 29 PM Screenshot 2025-07-31 at 4 05 03 PM

Full Diff

diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 44c1875d40..6f4bcb26e6 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -10,11 +10,11 @@ use crate::tui;
 use codex_core::config::Config;
 use codex_core::protocol::Event;
 use codex_core::protocol::EventMsg;
-use codex_core::protocol::ExecApprovalRequestEvent;
 use color_eyre::eyre::Result;
 use crossterm::SynchronizedUpdate;
 use crossterm::event::KeyCode;
 use crossterm::event::KeyEvent;
+use crossterm::event::KeyEventKind;
 use ratatui::layout::Offset;
 use ratatui::prelude::Backend;
 use std::path::PathBuf;
@@ -211,6 +211,7 @@ impl App<'_> {
                         KeyEvent {
                             code: KeyCode::Char('c'),
                             modifiers: crossterm::event::KeyModifiers::CONTROL,
+                            kind: KeyEventKind::Press,
                             ..
                         } => {
                             match &mut self.app_state {
@@ -225,6 +226,7 @@ impl App<'_> {
                         KeyEvent {
                             code: KeyCode::Char('d'),
                             modifiers: crossterm::event::KeyModifiers::CONTROL,
+                            kind: KeyEventKind::Press,
                             ..
                         } => {
                             match &mut self.app_state {
@@ -302,14 +304,41 @@ impl App<'_> {
                     }
                     #[cfg(debug_assertions)]
                     SlashCommand::TestApproval => {
+                        use std::collections::HashMap;
+
+                        use codex_core::protocol::ApplyPatchApprovalRequestEvent;
+                        use codex_core::protocol::FileChange;
+
                         self.app_event_tx.send(AppEvent::CodexEvent(Event {
                             id: "1".to_string(),
-                            msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
-                                call_id: "1".to_string(),
-                                command: vec!["git".into(), "apply".into()],
-                                cwd: self.config.cwd.clone(),
-                                reason: Some("test".to_string()),
-                            }),
+                            // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
+                            //     call_id: "1".to_string(),
+                            //     command: vec!["git".into(), "apply".into()],
+                            //     cwd: self.config.cwd.clone(),
+                            //     reason: Some("test".to_string()),
+                            // }),
+                            msg: EventMsg::ApplyPatchApprovalRequest(
+                                ApplyPatchApprovalRequestEvent {
+                                    call_id: "1".to_string(),
+                                    changes: HashMap::from([
+                                        (
+                                            PathBuf::from("/tmp/test.txt"),
+                                            FileChange::Add {
+                                                content: "test".to_string(),
+                                            },
+                                        ),
+                                        (
+                                            PathBuf::from("/tmp/test2.txt"),
+                                            FileChange::Update {
+                                                unified_diff: "+test\n-test2".to_string(),
+                                                move_path: None,
+                                            },
+                                        ),
+                                    ]),
+                                    reason: None,
+                                    grant_root: Some(PathBuf::from("/tmp")),
+                                },
+                            ),
                         }));
                     }
                 },
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 3ee724e687..83ea792002 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -25,6 +25,7 @@ use codex_core::protocol::PatchApplyBeginEvent;
 use codex_core::protocol::TaskCompleteEvent;
 use codex_core::protocol::TokenUsage;
 use crossterm::event::KeyEvent;
+use crossterm::event::KeyEventKind;
 use ratatui::buffer::Buffer;
 use ratatui::layout::Rect;
 use ratatui::widgets::Widget;
@@ -157,7 +158,9 @@ impl ChatWidget<'_> {
     }
 
     pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
-        self.bottom_pane.clear_ctrl_c_quit_hint();
+        if key_event.kind == KeyEventKind::Press {
+            self.bottom_pane.clear_ctrl_c_quit_hint();
+        }
 
         match self.bottom_pane.handle_key_event(key_event) {
             InputResult::Submitted(text) => {
diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs
index 855a7ea3db..91febde208 100644
--- a/codex-rs/tui/src/user_approval_widget.rs
+++ b/codex-rs/tui/src/user_approval_widget.rs
@@ -7,23 +7,24 @@
 //! 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::text::Span;
-use ratatui::widgets::List;
+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 tui_input::Input;
-use tui_input::backend::crossterm::EventHandler;
 
 use crate::app_event::AppEvent;
 use crate::app_event_sender::AppEventSender;
@@ -47,68 +48,62 @@ pub(crate) enum ApprovalRequest {
 
 /// Options displayed in the *select* mode.
 struct SelectOption {
-    label: &'static str,
-    decision: Option<ReviewDecision>,
-    /// `true` when this option switches the widget to *input* mode.
-    enters_input_mode: bool,
+    label: Line<'static>,
+    description: &'static str,
+    key: KeyCode,
+    decision: ReviewDecision,
 }
 
-// keep in same order as in the TS implementation
-const SELECT_OPTIONS: &[SelectOption] = &[
-    SelectOption {
-        label: "Yes (y)",
-        decision: Some(ReviewDecision::Approved),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "Yes, always approve this exact command for this session (a)",
-        decision: Some(ReviewDecision::ApprovedForSession),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "Edit or give feedback (e)",
-        decision: None,
-
-        enters_input_mode: true,
-    },
-    SelectOption {
-        label: "No, and keep going (n)",
-        decision: Some(ReviewDecision::Denied),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "No, and stop for now (esc)",
-        decision: Some(ReviewDecision::Abort),
-
-        enters_input_mode: false,
-    },
-];
-
-/// Internal mode the widget is in  mirrors the TypeScript component.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Mode {
-    Select,
-    Input,
-}
+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".into()]),
+            description: "Do not run the command",
+            key: KeyCode::Char('n'),
+            decision: ReviewDecision::Denied,
+        },
+    ]
+});
+
+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".into()]),
+            description: "Do not apply the changes",
+            key: KeyCode::Char('n'),
+            decision: ReviewDecision::Denied,
+        },
+    ]
+});
 
 /// A modal prompting the user to approve or deny the pending request.
 pub(crate) struct UserApprovalWidget<'a> {
     approval_request: ApprovalRequest,
     app_event_tx: AppEventSender,
     confirmation_prompt: Paragraph<'a>,
+    select_options: &'a Vec<SelectOption>,
 
     /// Currently selected index in *select* mode.
     selected_option: usize,
 
-    /// State for the optional input widget.
-    input: Input,
-
-    /// Current mode.
-    mode: Mode,
-
     /// Set to `true` once a decision has been sent  the parent view can then
     /// remove this widget from its queue.
     done: bool,
@@ -116,7 +111,6 @@ pub(crate) struct UserApprovalWidget<'a> {
 
 impl UserApprovalWidget<'_> {
     pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
-        let input = Input::default();
         let confirmation_prompt = match &approval_request {
             ApprovalRequest::Exec {
                 command,
@@ -132,25 +126,20 @@ impl UserApprovalWidget<'_> {
                     None => cwd.display().to_string(),
                 };
                 let mut contents: Vec<Line> = vec![
-                    Line::from(vec![
-                        Span::from(cwd_str).dim(),
-                        Span::from("$"),
-                        Span::from(format!(" {cmd}")),
-                    ]),
+                    Line::from(vec!["codex".bold().magenta(), " wants to run:".into()]),
+                    Line::from(vec![cwd_str.dim(), "$".into(), format!(" {cmd}").into()]),
                     Line::from(""),
                 ];
                 if let Some(reason) = reason {
                     contents.push(Line::from(reason.clone().italic()));
                     contents.push(Line::from(""));
                 }
-                contents.extend(vec![Line::from("Allow command?"), Line::from("")]);
                 Paragraph::new(contents).wrap(Wrap { trim: false })
             }
             ApprovalRequest::ApplyPatch {
                 reason, grant_root, ..
             } => {
-                let mut contents: Vec<Line> =
-                    vec![Line::from("Apply patch".bold()), Line::from("")];
+                let mut contents: Vec<Line> = vec![];
 
                 if let Some(r) = reason {
                     contents.push(Line::from(r.clone().italic()));
@@ -165,20 +154,19 @@ impl UserApprovalWidget<'_> {
                     contents.push(Line::from(""));
                 }
 
-                contents.push(Line::from("Allow changes?"));
-                contents.push(Line::from(""));
-
-                Paragraph::new(contents)
+                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,
-            input,
-            mode: Mode::Select,
             done: false,
         }
     }
@@ -194,9 +182,8 @@ impl UserApprovalWidget<'_> {
     /// 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) {
-        match self.mode {
-            Mode::Select => self.handle_select_key(key),
-            Mode::Input => self.handle_input_key(key),
+        if key.kind == KeyEventKind::Press {
+            self.handle_select_key(key);
         }
     }
 
@@ -208,58 +195,24 @@ impl UserApprovalWidget<'_> {
 
     fn handle_select_key(&mut self, key_event: KeyEvent) {
         match key_event.code {
-            KeyCode::Up => {
-                if self.selected_option == 0 {
-                    self.selected_option = SELECT_OPTIONS.len() - 1;
-                } else {
-                    self.selected_option -= 1;
-                }
+            KeyCode::Left => {
+                self.selected_option = (self.selected_option + self.select_options.len() - 1)
+                    % self.select_options.len();
             }
-            KeyCode::Down => {
-                self.selected_option = (self.selected_option + 1) % SELECT_OPTIONS.len();
-            }
-            KeyCode::Char('y') => {
-                self.send_decision(ReviewDecision::Approved);
-            }
-            KeyCode::Char('a') => {
-                self.send_decision(ReviewDecision::ApprovedForSession);
-            }
-            KeyCode::Char('n') => {
-                self.send_decision(ReviewDecision::Denied);
-            }
-            KeyCode::Char('e') => {
-                self.mode = Mode::Input;
+            KeyCode::Right => {
+                self.selected_option = (self.selected_option + 1) % self.select_options.len();
             }
             KeyCode::Enter => {
-                let opt = &SELECT_OPTIONS[self.selected_option];
-                if opt.enters_input_mode {
-                    self.mode = Mode::Input;
-                } else if let Some(decision) = opt.decision {
-                    self.send_decision(decision);
-                }
+                let opt = &self.select_options[self.selected_option];
+                self.send_decision(opt.decision);
             }
             KeyCode::Esc => {
                 self.send_decision(ReviewDecision::Abort);
             }
-            _ => {}
-        }
-    }
-
-    fn handle_input_key(&mut self, key_event: KeyEvent) {
-        // Handle special keys first.
-        match key_event.code {
-            KeyCode::Enter => {
-                let feedback = self.input.value().to_string();
-                self.send_decision_with_feedback(ReviewDecision::Denied, feedback);
-            }
-            KeyCode::Esc => {
-                // Cancel input  treat as deny without feedback.
-                self.send_decision(ReviewDecision::Denied);
-            }
-            _ => {
-                // Feed into input widget for normal editing.
-                let ct_event = crossterm::event::Event::Key(key_event);
-                self.input.handle_event(&ct_event);
+            other => {
+                if let Some(opt) = self.select_options.iter().find(|opt| opt.key == other) {
+                    self.send_decision(opt.decision);
+                }
             }
         }
     }
@@ -312,87 +265,68 @@ impl UserApprovalWidget<'_> {
     }
 
     pub(crate) fn desired_height(&self, width: u16) -> u16 {
-        self.get_confirmation_prompt_height(width - 2) + SELECT_OPTIONS.len() as u16 + 2
+        self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
     }
 }
 
-const PLAIN: Style = Style::new();
-const BLUE_FG: Style = Style::new().fg(Color::LightCyan);
-
 impl WidgetRef for &UserApprovalWidget<'_> {
     fn render_ref(&self, area: Rect, buf: &mut Buffer) {
-        // Take the area, wrap it in a block with a border, and divide up the
-        // remaining area into two chunks: one for the confirmation prompt and
-        // one for the response.
-        let inner = area.inner(Margin::new(0, 2));
-
-        // Determine how many rows we can allocate for the static confirmation
-        // prompt while *always* keeping enough space for the interactive
-        // response area (select list or input field). When the full prompt
-        // would exceed the available height we truncate it so the response
-        // options never get pushed out of view. This keeps the approval modal
-        // usable even when the overall bottom viewport is small.
-
-        // Full height of the prompt (may be larger than the available area).
-        let full_prompt_height = self.get_confirmation_prompt_height(inner.width);
-
-        // Minimum rows that must remain for the interactive section.
-        let min_response_rows = match self.mode {
-            Mode::Select => SELECT_OPTIONS.len() as u16,
-            // In input mode we need exactly two rows: one for the guidance
-            // prompt and one for the single-line input field.
-            Mode::Input => 2,
-        };
-
-        // Clamp prompt height so confirmation + response never exceed the
-        // available space. `saturating_sub` avoids underflow when the area is
-        // too small even for the minimal layout  in this unlikely case we
-        // fall back to zero-height prompt so at least the options are
-        // visible.
-        let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows));
-
-        let chunks = Layout::default()
+        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)])
-            .split(inner);
-        let prompt_chunk = chunks[0];
-        let response_chunk = chunks[1];
-
-        // Build the inner lines based on the mode. Collect them into a List of
-        // non-wrapping lines rather than a Paragraph for predictable layout.
-        let lines = match self.mode {
-            Mode::Select => SELECT_OPTIONS
-                .iter()
-                .enumerate()
-                .map(|(idx, opt)| {
-                    let (prefix, style) = if idx == self.selected_option {
-                        ("▶", BLUE_FG)
-                    } else {
-                        (" ", PLAIN)
-                    };
-                    Line::styled(format!("  {prefix} {}", opt.label), style)
-                })
-                .collect(),
-            Mode::Input => {
-                vec![
-                    Line::from("Give the model feedback on this command:"),
-                    Line::from(self.input.value()),
-                ]
-            }
+            .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().bg(Color::DarkGray)
+                };
+                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?",
         };
-
-        let border = ("◢◤")
-            .repeat((area.width / 2).into())
-            .fg(Color::LightYellow);
-
-        border.render_ref(area, buf);
-        Paragraph::new(" Execution Request ".bold().black().on_light_yellow())
-            .alignment(Alignment::Center)
-            .render_ref(area, buf);
+        Line::from(title).render(title_area, buf);
 
         self.confirmation_prompt.clone().render(prompt_chunk, buf);
-        List::new(lines).render_ref(response_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);
+        }
 
-        border.render_ref(Rect::new(0, area.y + area.height - 1, area.width, 1), buf);
+        Line::from(self.select_options[self.selected_option].description)
+            .style(Style::new().italic().fg(Color::DarkGray))
+            .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,
+            );
     }
 }

Review Comments

codex-rs/tui/src/chatwidget.rs

@@ -157,7 +158,9 @@ impl ChatWidget<'_> {
     }
 
     pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
-        self.bottom_pane.clear_ctrl_c_quit_hint();
+        if key_event.kind == KeyEventKind::Press {

Why are we special casing Press now?

codex-rs/tui/src/user_approval_widget.rs

@@ -47,76 +47,72 @@ pub(crate) enum ApprovalRequest {
 
 /// Options displayed in the *select* mode.
 struct SelectOption {
-    label: &'static str,
-    decision: Option<ReviewDecision>,
-    /// `true` when this option switches the widget to *input* mode.
-    enters_input_mode: bool,
+    label: Line<'static>,
+    description: &'static str,
+    key: KeyCode,
+    decision: ReviewDecision,
 }
 
 // keep in same order as in the TS implementation
-const SELECT_OPTIONS: &[SelectOption] = &[
-    SelectOption {
-        label: "Yes (y)",
-        decision: Some(ReviewDecision::Approved),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "Yes, always approve this exact command for this session (a)",
-        decision: Some(ReviewDecision::ApprovedForSession),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "Edit or give feedback (e)",
-        decision: None,
-
-        enters_input_mode: true,
-    },
-    SelectOption {
-        label: "No, and keep going (n)",
-        decision: Some(ReviewDecision::Denied),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "No, and stop for now (esc)",
-        decision: Some(ReviewDecision::Abort),
-
-        enters_input_mode: false,
-    },
-];
-
-/// Internal mode the widget is in  mirrors the TypeScript component.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Mode {
-    Select,
-    Input,
-}
+use std::sync::LazyLock;

// keep in same order as in the TS implementation can probably go?

@@ -47,76 +47,72 @@ pub(crate) enum ApprovalRequest {
 
 /// Options displayed in the *select* mode.
 struct SelectOption {
-    label: &'static str,
-    decision: Option<ReviewDecision>,
-    /// `true` when this option switches the widget to *input* mode.
-    enters_input_mode: bool,
+    label: Line<'static>,
+    description: &'static str,
+    key: KeyCode,
+    decision: ReviewDecision,
 }
 
 // keep in same order as in the TS implementation
-const SELECT_OPTIONS: &[SelectOption] = &[
-    SelectOption {
-        label: "Yes (y)",
-        decision: Some(ReviewDecision::Approved),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "Yes, always approve this exact command for this session (a)",
-        decision: Some(ReviewDecision::ApprovedForSession),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "Edit or give feedback (e)",
-        decision: None,
-
-        enters_input_mode: true,
-    },
-    SelectOption {
-        label: "No, and keep going (n)",
-        decision: Some(ReviewDecision::Denied),
-
-        enters_input_mode: false,
-    },
-    SelectOption {
-        label: "No, and stop for now (esc)",
-        decision: Some(ReviewDecision::Abort),
-
-        enters_input_mode: false,
-    },
-];
-
-/// Internal mode the widget is in  mirrors the TypeScript component.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Mode {
-    Select,
-    Input,
-}
+use std::sync::LazyLock;
+
+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".into()]),
+            description: "Do not run the command",
+            key: KeyCode::Char('n'),
+            decision: ReviewDecision::Denied,
+        },
+    ]
+});
+
+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".into()]),
+            description: "Do not apply the changes",
+            key: KeyCode::Char('n'),
+            decision: ReviewDecision::Denied,
+        },
+    ]
+});
 
 /// A modal prompting the user to approve or deny the pending request.
 pub(crate) struct UserApprovalWidget<'a> {
     approval_request: ApprovalRequest,
     app_event_tx: AppEventSender,
     confirmation_prompt: Paragraph<'a>,
+    select_options: &'a Vec<SelectOption>,

👍

@@ -312,87 +267,68 @@ impl UserApprovalWidget<'_> {
     }
 
     pub(crate) fn desired_height(&self, width: u16) -> u16 {
-        self.get_confirmation_prompt_height(width - 2) + SELECT_OPTIONS.len() as u16 + 2
+        self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
     }
 }
 
-const PLAIN: Style = Style::new();
-const BLUE_FG: Style = Style::new().fg(Color::LightCyan);
-
 impl WidgetRef for &UserApprovalWidget<'_> {
     fn render_ref(&self, area: Rect, buf: &mut Buffer) {
-        // Take the area, wrap it in a block with a border, and divide up the
-        // remaining area into two chunks: one for the confirmation prompt and
-        // one for the response.
-        let inner = area.inner(Margin::new(0, 2));
-
-        // Determine how many rows we can allocate for the static confirmation
-        // prompt while *always* keeping enough space for the interactive
-        // response area (select list or input field). When the full prompt
-        // would exceed the available height we truncate it so the response
-        // options never get pushed out of view. This keeps the approval modal
-        // usable even when the overall bottom viewport is small.
-
-        // Full height of the prompt (may be larger than the available area).
-        let full_prompt_height = self.get_confirmation_prompt_height(inner.width);
-
-        // Minimum rows that must remain for the interactive section.
-        let min_response_rows = match self.mode {
-            Mode::Select => SELECT_OPTIONS.len() as u16,
-            // In input mode we need exactly two rows: one for the guidance
-            // prompt and one for the single-line input field.
-            Mode::Input => 2,
-        };
-
-        // Clamp prompt height so confirmation + response never exceed the
-        // available space. `saturating_sub` avoids underflow when the area is
-        // too small even for the minimal layout  in this unlikely case we
-        // fall back to zero-height prompt so at least the options are
-        // visible.
-        let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows));
-
-        let chunks = Layout::default()
+        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)])
-            .split(inner);
-        let prompt_chunk = chunks[0];
-        let response_chunk = chunks[1];
-
-        // Build the inner lines based on the mode. Collect them into a List of
-        // non-wrapping lines rather than a Paragraph for predictable layout.
-        let lines = match self.mode {
-            Mode::Select => SELECT_OPTIONS
-                .iter()
-                .enumerate()
-                .map(|(idx, opt)| {
-                    let (prefix, style) = if idx == self.selected_option {
-                        ("▶", BLUE_FG)
-                    } else {
-                        (" ", PLAIN)
-                    };
-                    Line::styled(format!("  {prefix} {}", opt.label), style)
-                })
-                .collect(),
-            Mode::Input => {
-                vec![
-                    Line::from("Give the model feedback on this command:"),
-                    Line::from(self.input.value()),
-                ]
-            }
+            .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().bg(Color::DarkGray)

This just made me think: have you been testing on both light and dark backgrounds?