From ea69a1d72f11d0b1d24f9722dd3c31ff0fe69763 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Thu, 31 Jul 2025 17:10:52 -0700
Subject: [PATCH] lighter approval modal (#1768)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
codex-rs/tui/src/app.rs | 40 ++-
codex-rs/tui/src/chatwidget.rs | 5 +-
codex-rs/tui/src/user_approval_widget.rs | 306 +++++++++--------------
3 files changed, 157 insertions(+), 194 deletions(-)
diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 86c042f8..7454a1ca 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -10,7 +10,6 @@ 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;
@@ -313,14 +312,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 50488f8b..b0a6fb44 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 855a7ea3..91febde2 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 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::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,
- /// `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),
+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".into()]),
+ description: "Do not run the command",
+ key: KeyCode::Char('n'),
+ decision: ReviewDecision::Denied,
+ },
+ ]
+});
- 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 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".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,
/// 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 = 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 =
- vec![Line::from("Apply patch".bold()), Line::from("")];
+ let mut contents: Vec = 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 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) {
- 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];
+ .areas(area);
- // 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()),
- ]
- }
+ 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().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,
+ );
}
}