Asking for approval: <img width="269" height="41" alt="image" src="https://github.com/user-attachments/assets/b9ced569-3297-4dae-9ce7-0b015c9e14ea" /> Allow: <img width="400" height="31" alt="image" src="https://github.com/user-attachments/assets/92056b22-efda-4d49-854d-e2943d5fcf17" /> Reject: <img width="372" height="30" alt="image" src="https://github.com/user-attachments/assets/be9530a9-7d41-4800-bb42-abb9a24fc3ea" /> Always Approve: <img width="410" height="36" alt="image" src="https://github.com/user-attachments/assets/acf871ba-4c26-4501-b303-7956d0151754" />
446 lines
16 KiB
Rust
446 lines
16 KiB
Rust
//! A modal widget that prompts the user to approve or deny an action
|
||
//! requested by the agent.
|
||
//!
|
||
//! This is a (very) rough port of
|
||
//! `src/components/chat/terminal-chat-command-review.tsx` from the TypeScript
|
||
//! UI to Rust using [`ratatui`]. The goal is feature‑parity for the keyboard
|
||
//! driven workflow – a fully‑fledged visual match is not required.
|
||
|
||
use std::path::PathBuf;
|
||
use std::sync::LazyLock;
|
||
|
||
use codex_core::protocol::Op;
|
||
use codex_core::protocol::ReviewDecision;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyEventKind;
|
||
use ratatui::buffer::Buffer;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::prelude::*;
|
||
use ratatui::text::Line;
|
||
use ratatui::widgets::Block;
|
||
use ratatui::widgets::BorderType;
|
||
use ratatui::widgets::Borders;
|
||
use ratatui::widgets::Paragraph;
|
||
use ratatui::widgets::Widget;
|
||
use ratatui::widgets::WidgetRef;
|
||
use ratatui::widgets::Wrap;
|
||
|
||
use crate::app_event::AppEvent;
|
||
use crate::app_event_sender::AppEventSender;
|
||
use crate::exec_command::strip_bash_lc_and_escape;
|
||
|
||
/// Request coming from the agent that needs user approval.
|
||
pub(crate) enum ApprovalRequest {
|
||
Exec {
|
||
id: String,
|
||
command: Vec<String>,
|
||
#[allow(dead_code)]
|
||
cwd: PathBuf,
|
||
reason: Option<String>,
|
||
},
|
||
ApplyPatch {
|
||
id: String,
|
||
reason: Option<String>,
|
||
grant_root: Option<PathBuf>,
|
||
},
|
||
}
|
||
|
||
/// Options displayed in the *select* mode.
|
||
///
|
||
/// The `key` is matched case-insensitively.
|
||
struct SelectOption {
|
||
label: Line<'static>,
|
||
description: &'static str,
|
||
key: KeyCode,
|
||
decision: ReviewDecision,
|
||
}
|
||
|
||
static COMMAND_SELECT_OPTIONS: LazyLock<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,
|
||
|
||
/// Set to `true` once a decision has been sent – the parent view can then
|
||
/// remove this widget from its queue.
|
||
done: bool,
|
||
}
|
||
|
||
impl UserApprovalWidget<'_> {
|
||
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||
let confirmation_prompt = match &approval_request {
|
||
ApprovalRequest::Exec {
|
||
command, reason, ..
|
||
} => {
|
||
let cmd = strip_bash_lc_and_escape(command);
|
||
// Present a single-line summary without cwd: "codex wants to run: <cmd>"
|
||
let mut cmd_span: Span = cmd.clone().into();
|
||
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
|
||
let mut contents: Vec<Line> = vec![
|
||
Line::from(vec![
|
||
"? ".fg(Color::Blue),
|
||
"Codex wants to run ".bold(),
|
||
cmd_span,
|
||
]),
|
||
Line::from(""),
|
||
];
|
||
if let Some(reason) = reason {
|
||
contents.push(Line::from(reason.clone().italic()));
|
||
contents.push(Line::from(""));
|
||
}
|
||
Paragraph::new(contents).wrap(Wrap { trim: false })
|
||
}
|
||
ApprovalRequest::ApplyPatch {
|
||
reason, grant_root, ..
|
||
} => {
|
||
let mut contents: Vec<Line> = vec![];
|
||
|
||
if let Some(r) = reason {
|
||
contents.push(Line::from(r.clone().italic()));
|
||
contents.push(Line::from(""));
|
||
}
|
||
|
||
if let Some(root) = grant_root {
|
||
contents.push(Line::from(format!(
|
||
"This will grant write access to {} for the remainder of this session.",
|
||
root.display()
|
||
)));
|
||
contents.push(Line::from(""));
|
||
}
|
||
|
||
Paragraph::new(contents).wrap(Wrap { trim: false })
|
||
}
|
||
};
|
||
|
||
Self {
|
||
select_options: match &approval_request {
|
||
ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS,
|
||
ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS,
|
||
},
|
||
approval_request,
|
||
app_event_tx,
|
||
confirmation_prompt,
|
||
selected_option: 0,
|
||
done: false,
|
||
}
|
||
}
|
||
|
||
fn get_confirmation_prompt_height(&self, width: u16) -> u16 {
|
||
// Should cache this for last value of width.
|
||
self.confirmation_prompt.line_count(width) as u16
|
||
}
|
||
|
||
/// Process a `KeyEvent` coming from crossterm. Always consumes the event
|
||
/// while the modal is visible.
|
||
/// Process a key event originating from crossterm. As the modal fully
|
||
/// captures input while visible, we don’t need to report whether the event
|
||
/// was consumed—callers can assume it always is.
|
||
pub(crate) fn handle_key_event(&mut self, key: KeyEvent) {
|
||
if key.kind == KeyEventKind::Press {
|
||
self.handle_select_key(key);
|
||
}
|
||
}
|
||
|
||
/// Normalize a key for comparison.
|
||
/// - For `KeyCode::Char`, converts to lowercase for case-insensitive matching.
|
||
/// - Other key codes are returned unchanged.
|
||
fn normalize_keycode(code: KeyCode) -> KeyCode {
|
||
match code {
|
||
KeyCode::Char(c) => KeyCode::Char(c.to_ascii_lowercase()),
|
||
other => other,
|
||
}
|
||
}
|
||
|
||
/// Handle Ctrl-C pressed by the user while the modal is visible.
|
||
/// Behaves like pressing Escape: abort the request and close the modal.
|
||
pub(crate) fn on_ctrl_c(&mut self) {
|
||
self.send_decision(ReviewDecision::Abort);
|
||
}
|
||
|
||
fn handle_select_key(&mut self, key_event: KeyEvent) {
|
||
match key_event.code {
|
||
KeyCode::Left => {
|
||
self.selected_option = (self.selected_option + self.select_options.len() - 1)
|
||
% self.select_options.len();
|
||
}
|
||
KeyCode::Right => {
|
||
self.selected_option = (self.selected_option + 1) % self.select_options.len();
|
||
}
|
||
KeyCode::Enter => {
|
||
let opt = &self.select_options[self.selected_option];
|
||
self.send_decision(opt.decision);
|
||
}
|
||
KeyCode::Esc => {
|
||
self.send_decision(ReviewDecision::Abort);
|
||
}
|
||
other => {
|
||
let normalized = Self::normalize_keycode(other);
|
||
if let Some(opt) = self
|
||
.select_options
|
||
.iter()
|
||
.find(|opt| Self::normalize_keycode(opt.key) == normalized)
|
||
{
|
||
self.send_decision(opt.decision);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn send_decision(&mut self, decision: ReviewDecision) {
|
||
self.send_decision_with_feedback(decision, String::new())
|
||
}
|
||
|
||
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
|
||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||
match &self.approval_request {
|
||
ApprovalRequest::Exec { command, .. } => {
|
||
let cmd = strip_bash_lc_and_escape(command);
|
||
let mut cmd_span: Span = cmd.clone().into();
|
||
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
|
||
|
||
// Result line based on decision.
|
||
match decision {
|
||
ReviewDecision::Approved => {
|
||
lines.push(Line::from(vec![
|
||
"✓ ".fg(Color::Green),
|
||
"You ".into(),
|
||
"approved".bold(),
|
||
" codex to run ".into(),
|
||
cmd_span,
|
||
" ".into(),
|
||
"this time".bold(),
|
||
]));
|
||
}
|
||
ReviewDecision::ApprovedForSession => {
|
||
lines.push(Line::from(vec![
|
||
"✓ ".fg(Color::Green),
|
||
"You ".into(),
|
||
"approved".bold(),
|
||
" codex to run ".into(),
|
||
cmd_span,
|
||
" ".into(),
|
||
"every time this session".bold(),
|
||
]));
|
||
}
|
||
ReviewDecision::Denied => {
|
||
lines.push(Line::from(vec![
|
||
"✗ ".fg(Color::Red),
|
||
"You ".into(),
|
||
"did not approve".bold(),
|
||
" codex to run ".into(),
|
||
cmd_span,
|
||
]));
|
||
}
|
||
ReviewDecision::Abort => {
|
||
lines.push(Line::from(vec![
|
||
"✗ ".fg(Color::Red),
|
||
"You ".into(),
|
||
"canceled".bold(),
|
||
" the request to run ".into(),
|
||
cmd_span,
|
||
]));
|
||
}
|
||
}
|
||
}
|
||
ApprovalRequest::ApplyPatch { .. } => {
|
||
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
|
||
}
|
||
}
|
||
if !feedback.trim().is_empty() {
|
||
lines.push(Line::from("feedback:"));
|
||
for l in feedback.lines() {
|
||
lines.push(Line::from(l.to_string()));
|
||
}
|
||
}
|
||
lines.push(Line::from(""));
|
||
self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||
|
||
let op = match &self.approval_request {
|
||
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
|
||
id: id.clone(),
|
||
decision,
|
||
},
|
||
ApprovalRequest::ApplyPatch { id, .. } => Op::PatchApproval {
|
||
id: id.clone(),
|
||
decision,
|
||
},
|
||
};
|
||
|
||
self.app_event_tx.send(AppEvent::CodexOp(op));
|
||
self.done = true;
|
||
}
|
||
|
||
/// Returns `true` once the user has made a decision and the widget no
|
||
/// longer needs to be displayed.
|
||
pub(crate) fn is_complete(&self) -> bool {
|
||
self.done
|
||
}
|
||
|
||
pub(crate) fn desired_height(&self, width: u16) -> u16 {
|
||
self.get_confirmation_prompt_height(width) + self.select_options.len() as u16
|
||
}
|
||
}
|
||
|
||
impl WidgetRef for &UserApprovalWidget<'_> {
|
||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||
let prompt_height = self.get_confirmation_prompt_height(area.width);
|
||
let [prompt_chunk, response_chunk] = Layout::default()
|
||
.direction(Direction::Vertical)
|
||
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
||
.areas(area);
|
||
|
||
let lines: Vec<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?",
|
||
};
|
||
Line::from(title).render(title_area, buf);
|
||
|
||
self.confirmation_prompt.clone().render(prompt_chunk, buf);
|
||
let areas = Layout::horizontal(
|
||
lines
|
||
.iter()
|
||
.map(|l| Constraint::Length(l.width() as u16 + 2)),
|
||
)
|
||
.spacing(1)
|
||
.split(button_area);
|
||
for (idx, area) in areas.iter().enumerate() {
|
||
let line = &lines[idx];
|
||
line.render(*area, buf);
|
||
}
|
||
|
||
Line::from(self.select_options[self.selected_option].description)
|
||
.style(Style::new().italic().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,
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crossterm::event::KeyCode;
|
||
use crossterm::event::KeyEvent;
|
||
use crossterm::event::KeyModifiers;
|
||
use std::path::PathBuf;
|
||
use std::sync::mpsc::channel;
|
||
|
||
#[test]
|
||
fn lowercase_shortcut_is_accepted() {
|
||
let (tx_raw, rx) = channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let req = ApprovalRequest::Exec {
|
||
id: "1".to_string(),
|
||
command: vec!["echo".to_string()],
|
||
cwd: PathBuf::new(),
|
||
reason: None,
|
||
};
|
||
let mut widget = UserApprovalWidget::new(req, tx);
|
||
widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||
assert!(widget.is_complete());
|
||
let events: Vec<AppEvent> = rx.try_iter().collect();
|
||
assert!(events.iter().any(|e| matches!(
|
||
e,
|
||
AppEvent::CodexOp(Op::ExecApproval {
|
||
decision: ReviewDecision::Approved,
|
||
..
|
||
})
|
||
)));
|
||
}
|
||
|
||
#[test]
|
||
fn uppercase_shortcut_is_accepted() {
|
||
let (tx_raw, rx) = channel::<AppEvent>();
|
||
let tx = AppEventSender::new(tx_raw);
|
||
let req = ApprovalRequest::Exec {
|
||
id: "2".to_string(),
|
||
command: vec!["echo".to_string()],
|
||
cwd: PathBuf::new(),
|
||
reason: None,
|
||
};
|
||
let mut widget = UserApprovalWidget::new(req, tx);
|
||
widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE));
|
||
assert!(widget.is_complete());
|
||
let events: Vec<AppEvent> = rx.try_iter().collect();
|
||
assert!(events.iter().any(|e| matches!(
|
||
e,
|
||
AppEvent::CodexOp(Op::ExecApproval {
|
||
decision: ReviewDecision::Approved,
|
||
..
|
||
})
|
||
)));
|
||
}
|
||
}
|