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

839 lines
31 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR #1768: lighter approval modal
- URL: https://github.com/openai/codex/pull/1768
- Author: nornagon-openai
- Created: 2025-07-31 23:05:23 UTC
- Updated: 2025-08-01 00:11:00 UTC
- Changes: +162/-196, Files changed: 3, Commits: 2
## 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.
<img width="860" height="390" alt="Screenshot 2025-07-31 at 4 03 29PM" src="https://github.com/user-attachments/assets/18476e1a-065d-4da9-92fe-e94978ab0fce" />
<img width="860" height="390" alt="Screenshot 2025-07-31 at 4 05 03PM" src="https://github.com/user-attachments/assets/337db0da-de40-48c6-ae71-0e40f24b87e7" />
## Full Diff
```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
- Created: 2025-07-31 23:10:35 UTC | Link: https://github.com/openai/codex/pull/1768#discussion_r2246547801
```diff
@@ -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
- Created: 2025-07-31 23:46:03 UTC | Link: https://github.com/openai/codex/pull/1768#discussion_r2246581651
```diff
@@ -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?
- Created: 2025-07-31 23:46:37 UTC | Link: https://github.com/openai/codex/pull/1768#discussion_r2246582054
```diff
@@ -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>,
```
> 👍
- Created: 2025-07-31 23:51:28 UTC | Link: https://github.com/openai/codex/pull/1768#discussion_r2246586346
```diff
@@ -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?