mirror of
https://github.com/openai/codex.git
synced 2026-04-28 16:45:54 +00:00
839 lines
31 KiB
Markdown
839 lines
31 KiB
Markdown
# 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 29 PM" 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 03 PM" 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 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<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 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];
|
||
-
|
||
- // 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? |