Move approvals to use ListSelectionView (#4275)
Unify selection menus: - Move approvals to the vertical menu `ListSelectionView` - Add header section to `ListSelectionView` <img width="502" height="214" alt="image" src="https://github.com/user-attachments/assets/f4b43ddf-3549-403c-ad9e-a523688714e4" /> <img width="748" height="214" alt="image" src="https://github.com/user-attachments/assets/f94ac7b5-dc94-4dc0-a1df-7a8e3ba2453b" /> --------- Co-authored-by: pakrym-oai <pakrym@openai.com>
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crate::user_approval_widget::UserApprovalWidget;
|
||||
|
||||
use super::BottomPaneView;
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// Modal overlay asking the user to approve/deny a sequence of requests.
|
||||
pub(crate) struct ApprovalModalView {
|
||||
current: UserApprovalWidget,
|
||||
queue: Vec<ApprovalRequest>,
|
||||
app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl ApprovalModalView {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
current: UserApprovalWidget::new(request, app_event_tx.clone()),
|
||||
queue: Vec::new(),
|
||||
app_event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
|
||||
self.queue.push(req);
|
||||
}
|
||||
|
||||
/// Advance to next request if the current one is finished.
|
||||
fn maybe_advance(&mut self) {
|
||||
if self.current.is_complete()
|
||||
&& let Some(req) = self.queue.pop()
|
||||
{
|
||||
self.current = UserApprovalWidget::new(req, self.app_event_tx.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ApprovalModalView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
self.current.handle_key_event(key_event);
|
||||
self.maybe_advance();
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.current.on_ctrl_c();
|
||||
self.queue.clear();
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.current.is_complete() && self.queue.is_empty()
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.current.desired_height(width)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
(&self.current).render_ref(area, buf);
|
||||
}
|
||||
|
||||
fn try_consume_approval_request(&mut self, req: ApprovalRequest) -> Option<ApprovalRequest> {
|
||||
self.enqueue_request(req);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
id: "test".to_string(),
|
||||
command: vec!["echo".to_string(), "hi".to_string()],
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_aborts_and_clears_queue() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let first = make_exec_request();
|
||||
let mut view = ApprovalModalView::new(first, tx);
|
||||
view.enqueue_request(make_exec_request());
|
||||
|
||||
let (tx2, _rx2) = unbounded_channel::<AppEvent>();
|
||||
// Why do we have this?
|
||||
let _pane = BottomPane::new(super::super::BottomPaneParams {
|
||||
app_event_tx: AppEventSender::new(tx2),
|
||||
frame_requester: crate::tui::FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
});
|
||||
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
|
||||
assert!(view.queue.is_empty());
|
||||
assert!(view.current.is_complete());
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
}
|
||||
559
codex-rs/tui/src/bottom_pane/approval_overlay.rs
Normal file
559
codex-rs/tui/src/bottom_pane/approval_overlay.rs
Normal file
@@ -0,0 +1,559 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::list_selection_view::HeaderLine;
|
||||
use crate::bottom_pane::list_selection_view::ListSelectionView;
|
||||
use crate::bottom_pane::list_selection_view::SelectionItem;
|
||||
use crate::bottom_pane::list_selection_view::SelectionViewParams;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
pub(crate) enum ApprovalRequest {
|
||||
Exec {
|
||||
id: String,
|
||||
command: Vec<String>,
|
||||
reason: Option<String>,
|
||||
},
|
||||
ApplyPatch {
|
||||
id: String,
|
||||
reason: Option<String>,
|
||||
grant_root: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Modal overlay asking the user to approve or deny one or more requests.
|
||||
pub(crate) struct ApprovalOverlay {
|
||||
current: Option<ApprovalRequestState>,
|
||||
queue: Vec<ApprovalRequest>,
|
||||
app_event_tx: AppEventSender,
|
||||
list: ListSelectionView,
|
||||
options: Vec<ApprovalOption>,
|
||||
current_complete: bool,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl ApprovalOverlay {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
let mut view = Self {
|
||||
current: Some(ApprovalRequestState::from(request)),
|
||||
queue: Vec::new(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
list: ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: String::new(),
|
||||
..Default::default()
|
||||
},
|
||||
app_event_tx,
|
||||
),
|
||||
options: Vec::new(),
|
||||
current_complete: false,
|
||||
done: false,
|
||||
};
|
||||
let (options, params) = view.build_options();
|
||||
view.options = options;
|
||||
view.list = ListSelectionView::new(params, view.app_event_tx.clone());
|
||||
view
|
||||
}
|
||||
|
||||
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
|
||||
self.queue.push(req);
|
||||
}
|
||||
|
||||
fn set_current(&mut self, request: ApprovalRequest) {
|
||||
self.current = Some(ApprovalRequestState::from(request));
|
||||
self.current_complete = false;
|
||||
let (options, params) = self.build_options();
|
||||
self.options = options;
|
||||
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
}
|
||||
|
||||
fn build_options(&self) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
||||
let Some(state) = self.current.as_ref() else {
|
||||
return (
|
||||
Vec::new(),
|
||||
SelectionViewParams {
|
||||
title: String::new(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
};
|
||||
let (options, title) = match &state.variant {
|
||||
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
|
||||
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
|
||||
};
|
||||
|
||||
let items = options
|
||||
.iter()
|
||||
.map(|opt| SelectionItem {
|
||||
name: opt.label.clone(),
|
||||
description: Some(opt.description.clone()),
|
||||
is_current: false,
|
||||
actions: Vec::new(),
|
||||
dismiss_on_select: false,
|
||||
search_value: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let params = SelectionViewParams {
|
||||
title,
|
||||
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
|
||||
items,
|
||||
header: state.header.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
(options, params)
|
||||
}
|
||||
|
||||
fn apply_selection(&mut self, actual_idx: usize) {
|
||||
if self.current_complete {
|
||||
return;
|
||||
}
|
||||
let Some(option) = self.options.get(actual_idx) else {
|
||||
return;
|
||||
};
|
||||
if let Some(state) = self.current.as_ref() {
|
||||
match (&state.variant, option.decision) {
|
||||
(ApprovalVariant::Exec { id, command }, decision) => {
|
||||
self.handle_exec_decision(id, command, decision);
|
||||
}
|
||||
(ApprovalVariant::ApplyPatch { id, .. }, decision) => {
|
||||
self.handle_patch_decision(id, decision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.current_complete = true;
|
||||
self.advance_queue();
|
||||
}
|
||||
|
||||
fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) {
|
||||
if let Some(lines) = build_exec_history_lines(command.to_vec(), decision) {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_user_approval_decision(lines),
|
||||
)));
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval {
|
||||
id: id.to_string(),
|
||||
decision,
|
||||
}));
|
||||
}
|
||||
|
||||
fn handle_patch_decision(&self, id: &str, decision: ReviewDecision) {
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::PatchApproval {
|
||||
id: id.to_string(),
|
||||
decision,
|
||||
}));
|
||||
}
|
||||
|
||||
fn advance_queue(&mut self) {
|
||||
if let Some(next) = self.queue.pop() {
|
||||
self.set_current(next);
|
||||
} else {
|
||||
self.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
||||
if key_event.kind != KeyEventKind::Press {
|
||||
return false;
|
||||
}
|
||||
let KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} = key_event
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) {
|
||||
return false;
|
||||
}
|
||||
let lower = c.to_ascii_lowercase();
|
||||
if let Some(idx) = self
|
||||
.options
|
||||
.iter()
|
||||
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
|
||||
{
|
||||
self.apply_selection(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ApprovalOverlay {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if self.try_handle_shortcut(&key_event) {
|
||||
return;
|
||||
}
|
||||
self.list.handle_key_event(key_event);
|
||||
if let Some(idx) = self.list.take_last_selected_index() {
|
||||
self.apply_selection(idx);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if self.done {
|
||||
return CancellationEvent::Handled;
|
||||
}
|
||||
if !self.current_complete
|
||||
&& let Some(state) = self.current.as_ref()
|
||||
{
|
||||
match &state.variant {
|
||||
ApprovalVariant::Exec { id, command } => {
|
||||
self.handle_exec_decision(id, command, ReviewDecision::Abort);
|
||||
}
|
||||
ApprovalVariant::ApplyPatch { id, .. } => {
|
||||
self.handle_patch_decision(id, ReviewDecision::Abort);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.queue.clear();
|
||||
self.done = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.list.desired_height(width)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.list.render(area, buf);
|
||||
}
|
||||
|
||||
fn try_consume_approval_request(
|
||||
&mut self,
|
||||
request: ApprovalRequest,
|
||||
) -> Option<ApprovalRequest> {
|
||||
self.enqueue_request(request);
|
||||
None
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
self.list.cursor_pos(area)
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalRequestState {
|
||||
variant: ApprovalVariant,
|
||||
header: Vec<HeaderLine>,
|
||||
}
|
||||
|
||||
impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
fn from(value: ApprovalRequest) -> Self {
|
||||
match value {
|
||||
ApprovalRequest::Exec {
|
||||
id,
|
||||
command,
|
||||
reason,
|
||||
} => {
|
||||
let mut header = Vec::new();
|
||||
if let Some(reason) = reason
|
||||
&& !reason.is_empty()
|
||||
{
|
||||
header.push(HeaderLine::Text {
|
||||
text: reason,
|
||||
italic: true,
|
||||
});
|
||||
header.push(HeaderLine::Spacer);
|
||||
}
|
||||
let command_snippet = exec_snippet(&command);
|
||||
if !command_snippet.is_empty() {
|
||||
header.push(HeaderLine::Text {
|
||||
text: format!("Command: {command_snippet}"),
|
||||
italic: false,
|
||||
});
|
||||
header.push(HeaderLine::Spacer);
|
||||
}
|
||||
Self {
|
||||
variant: ApprovalVariant::Exec { id, command },
|
||||
header,
|
||||
}
|
||||
}
|
||||
ApprovalRequest::ApplyPatch {
|
||||
id,
|
||||
reason,
|
||||
grant_root,
|
||||
} => {
|
||||
let mut header = Vec::new();
|
||||
if let Some(reason) = reason
|
||||
&& !reason.is_empty()
|
||||
{
|
||||
header.push(HeaderLine::Text {
|
||||
text: reason,
|
||||
italic: true,
|
||||
});
|
||||
header.push(HeaderLine::Spacer);
|
||||
}
|
||||
if let Some(root) = grant_root {
|
||||
header.push(HeaderLine::Text {
|
||||
text: format!(
|
||||
"Grant write access to {} for the remainder of this session.",
|
||||
root.display()
|
||||
),
|
||||
italic: false,
|
||||
});
|
||||
header.push(HeaderLine::Spacer);
|
||||
}
|
||||
Self {
|
||||
variant: ApprovalVariant::ApplyPatch { id },
|
||||
header,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalVariant {
|
||||
Exec { id: String, command: Vec<String> },
|
||||
ApplyPatch { id: String },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ApprovalOption {
|
||||
label: String,
|
||||
description: String,
|
||||
decision: ReviewDecision,
|
||||
shortcut: Option<char>,
|
||||
}
|
||||
|
||||
fn exec_options() -> Vec<ApprovalOption> {
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Approve and run now".to_string(),
|
||||
description: "(Y) Run this command one time".to_string(),
|
||||
decision: ReviewDecision::Approved,
|
||||
shortcut: Some('y'),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Always approve this session".to_string(),
|
||||
description: "(A) Automatically approve this command for the rest of the session"
|
||||
.to_string(),
|
||||
decision: ReviewDecision::ApprovedForSession,
|
||||
shortcut: Some('a'),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Cancel".to_string(),
|
||||
description: "(N) Do not run the command".to_string(),
|
||||
decision: ReviewDecision::Abort,
|
||||
shortcut: Some('n'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn patch_options() -> Vec<ApprovalOption> {
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Approve".to_string(),
|
||||
description: "(Y) Apply the proposed changes".to_string(),
|
||||
decision: ReviewDecision::Approved,
|
||||
shortcut: Some('y'),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Cancel".to_string(),
|
||||
description: "(N) Do not apply the changes".to_string(),
|
||||
decision: ReviewDecision::Abort,
|
||||
shortcut: Some('n'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn build_exec_history_lines(
|
||||
command: Vec<String>,
|
||||
decision: ReviewDecision,
|
||||
) -> Option<Vec<Line<'static>>> {
|
||||
use ReviewDecision::*;
|
||||
|
||||
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
|
||||
Approved => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
"✔ ".green(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet,
|
||||
" this time".bold(),
|
||||
],
|
||||
)
|
||||
}
|
||||
ApprovedForSession => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
"✔ ".green(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet,
|
||||
" every time this session".bold(),
|
||||
],
|
||||
)
|
||||
}
|
||||
Denied => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
"✗ ".red(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"did not approve".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet,
|
||||
],
|
||||
)
|
||||
}
|
||||
Abort => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
"✗ ".red(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"canceled".bold(),
|
||||
" the request to run ".into(),
|
||||
snippet,
|
||||
],
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut spans = Vec::new();
|
||||
spans.push(symbol);
|
||||
spans.extend(summary);
|
||||
lines.push(Line::from(spans));
|
||||
Some(lines)
|
||||
}
|
||||
|
||||
fn truncate_exec_snippet(full_cmd: &str) -> String {
|
||||
let mut snippet = match full_cmd.split_once('\n') {
|
||||
Some((first, _)) => format!("{first} ..."),
|
||||
None => full_cmd.to_string(),
|
||||
};
|
||||
snippet = truncate_text(&snippet, 80);
|
||||
snippet
|
||||
}
|
||||
|
||||
fn exec_snippet(command: &[String]) -> String {
|
||||
let full_cmd = strip_bash_lc_and_escape(command);
|
||||
truncate_exec_snippet(&full_cmd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn make_exec_request() -> ApprovalRequest {
|
||||
ApprovalRequest::Exec {
|
||||
id: "test".to_string(),
|
||||
command: vec!["echo".to_string(), "hi".to_string()],
|
||||
reason: Some("reason".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_aborts_and_clears_queue() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
|
||||
view.enqueue_request(make_exec_request());
|
||||
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
|
||||
assert!(view.queue.is_empty());
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_triggers_selection() {
|
||||
let (tx, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
|
||||
assert!(!view.is_complete());
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
// We expect at least one CodexOp message in the queue.
|
||||
let mut saw_op = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if matches!(ev, AppEvent::CodexOp(_)) {
|
||||
saw_op = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_op, "expected approval decision to emit an op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_includes_command_snippet() {
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx);
|
||||
let command = vec!["echo".into(), "hello".into(), "world".into()];
|
||||
let exec_request = ApprovalRequest::Exec {
|
||||
id: "test".into(),
|
||||
command,
|
||||
reason: None,
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6));
|
||||
view.render(Rect::new(0, 0, 80, 6), &mut buf);
|
||||
|
||||
let rendered: Vec<String> = (0..buf.area.height)
|
||||
.map(|row| {
|
||||
(0..buf.area.width)
|
||||
.map(|col| buf[(col, row)].symbol().to_string())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Command: echo hello world")),
|
||||
"expected header to include command snippet, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_sets_last_selected_index_without_dismissing() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = ApprovalOverlay::new(make_exec_request(), tx);
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
view.is_complete(),
|
||||
"exec approval should complete without queued requests"
|
||||
);
|
||||
|
||||
let mut decision = None;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::CodexOp(Op::ExecApproval { decision: d, .. }) = ev {
|
||||
decision = Some(d);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(decision, Some(ReviewDecision::ApprovedForSession));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
@@ -8,6 +8,7 @@ use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use textwrap::wrap;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
@@ -22,6 +23,12 @@ use super::selection_popup_common::render_rows;
|
||||
/// One selectable item in the generic selection list.
|
||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum HeaderLine {
|
||||
Text { text: String, italic: bool },
|
||||
Spacer,
|
||||
}
|
||||
|
||||
pub(crate) struct SelectionItem {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
@@ -39,6 +46,7 @@ pub(crate) struct SelectionViewParams {
|
||||
pub items: Vec<SelectionItem>,
|
||||
pub is_searchable: bool,
|
||||
pub search_placeholder: Option<String>,
|
||||
pub header: Vec<HeaderLine>,
|
||||
}
|
||||
|
||||
pub(crate) struct ListSelectionView {
|
||||
@@ -53,6 +61,8 @@ pub(crate) struct ListSelectionView {
|
||||
search_query: String,
|
||||
search_placeholder: Option<String>,
|
||||
filtered_indices: Vec<usize>,
|
||||
last_selected_actual_idx: Option<usize>,
|
||||
header: Vec<HeaderLine>,
|
||||
}
|
||||
|
||||
impl ListSelectionView {
|
||||
@@ -82,6 +92,8 @@ impl ListSelectionView {
|
||||
None
|
||||
},
|
||||
filtered_indices: Vec::new(),
|
||||
last_selected_actual_idx: None,
|
||||
header: params.header,
|
||||
};
|
||||
s.apply_filter();
|
||||
s
|
||||
@@ -198,6 +210,7 @@ impl ListSelectionView {
|
||||
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||
&& let Some(item) = self.items.get(*actual_idx)
|
||||
{
|
||||
self.last_selected_actual_idx = Some(*actual_idx);
|
||||
for act in &item.actions {
|
||||
act(&self.app_event_tx);
|
||||
}
|
||||
@@ -214,6 +227,43 @@ impl ListSelectionView {
|
||||
self.search_query = query;
|
||||
self.apply_filter();
|
||||
}
|
||||
|
||||
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
|
||||
self.last_selected_actual_idx.take()
|
||||
}
|
||||
|
||||
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
|
||||
if self.header.is_empty() || width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let prefix_width = Self::dim_prefix_span().width() as u16;
|
||||
let available = width.saturating_sub(prefix_width).max(1) as usize;
|
||||
let mut lines = Vec::new();
|
||||
for entry in &self.header {
|
||||
match entry {
|
||||
HeaderLine::Spacer => lines.push(Vec::new()),
|
||||
HeaderLine::Text { text, italic } => {
|
||||
if text.is_empty() {
|
||||
lines.push(Vec::new());
|
||||
continue;
|
||||
}
|
||||
for part in wrap(text, available) {
|
||||
let span = if *italic {
|
||||
Span::from(part.into_owned()).italic()
|
||||
} else {
|
||||
Span::from(part.into_owned())
|
||||
};
|
||||
lines.push(vec![span]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn header_height(&self, width: u16) -> u16 {
|
||||
self.header_spans_for_width(width).len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
@@ -276,7 +326,8 @@ impl BottomPaneView for ListSelectionView {
|
||||
|
||||
// +1 for the title row, +1 for a spacer line beneath the header,
|
||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
||||
let mut height = rows_height + 2;
|
||||
let mut height = self.header_height(width);
|
||||
height = height.saturating_add(rows_height + 2);
|
||||
if self.is_searchable {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
@@ -295,20 +346,46 @@ impl BottomPaneView for ListSelectionView {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut next_y = area.y;
|
||||
let header_spans = self.header_spans_for_width(area.width);
|
||||
for spans in header_spans.into_iter() {
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let row = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
|
||||
if spans.is_empty() {
|
||||
prefixed.push(String::new().into());
|
||||
} else {
|
||||
prefixed.extend(spans);
|
||||
}
|
||||
Paragraph::new(Line::from(prefixed)).render(row, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(Line::from(vec![
|
||||
Self::dim_prefix_span(),
|
||||
self.title.clone().bold(),
|
||||
]))
|
||||
.render(title_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
|
||||
let title_spans: Vec<Span<'static>> =
|
||||
vec![Self::dim_prefix_span(), self.title.clone().bold()];
|
||||
let title_para = Paragraph::new(Line::from(title_spans));
|
||||
title_para.render(title_area, buf);
|
||||
|
||||
let mut next_y = area.y.saturating_add(1);
|
||||
if self.is_searchable {
|
||||
if self.is_searchable && next_y < area.y + area.height {
|
||||
let search_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
@@ -327,20 +404,25 @@ impl BottomPaneView for ListSelectionView {
|
||||
.render(search_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if let Some(sub) = &self.subtitle {
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let subtitle_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let subtitle_spans: Vec<Span<'static>> =
|
||||
vec![Self::dim_prefix_span(), sub.clone().dim()];
|
||||
let subtitle_para = Paragraph::new(Line::from(subtitle_spans));
|
||||
subtitle_para.render(subtitle_area, buf);
|
||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
|
||||
.render(subtitle_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let spacer_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
@@ -351,6 +433,9 @@ impl BottomPaneView for ListSelectionView {
|
||||
next_y = next_y.saturating_add(1);
|
||||
|
||||
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let rows_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
@@ -381,8 +466,7 @@ impl BottomPaneView for ListSelectionView {
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let footer_para = Paragraph::new(hint.clone().dim());
|
||||
footer_para.render(footer_area, buf);
|
||||
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use codex_core::protocol::TokenUsageInfo;
|
||||
use codex_file_search::FileMatch;
|
||||
@@ -16,7 +15,9 @@ use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::time::Duration;
|
||||
|
||||
mod approval_modal_view;
|
||||
mod approval_overlay;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
pub(crate) use approval_overlay::ApprovalRequest;
|
||||
mod bottom_pane_view;
|
||||
mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
@@ -43,7 +44,6 @@ pub(crate) use chat_composer::InputResult;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
use approval_modal_view::ApprovalModalView;
|
||||
pub(crate) use list_selection_view::SelectionAction;
|
||||
pub(crate) use list_selection_view::SelectionItem;
|
||||
|
||||
@@ -397,7 +397,7 @@ impl BottomPane {
|
||||
};
|
||||
|
||||
// Otherwise create a new approval modal overlay.
|
||||
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
|
||||
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone());
|
||||
self.pause_status_timer_for_modal();
|
||||
self.push_view(Box::new(modal));
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ use crate::slash_command::SlashCommand;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
mod interrupts;
|
||||
use self::interrupts::InterruptManager;
|
||||
mod agent;
|
||||
|
||||
@@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"this is a test reason such as one that would be produced by the model "
|
||||
"▌ this is a test reason such as one that would be produced by the model "
|
||||
"▌ "
|
||||
"▌ Command: echo hello world "
|
||||
"▌ "
|
||||
"▌ Allow command? "
|
||||
"▌ "
|
||||
"▌ > 1. Approve and run now (Y) Run this command one time "
|
||||
"▌ 2. Always approve this session (A) Automatically approve this command for "
|
||||
"▌ the rest of the session "
|
||||
"▌ 3. Cancel (N) Do not run the command "
|
||||
" "
|
||||
"▌Allow command? "
|
||||
"▌ Yes Always No, provide feedback "
|
||||
"▌ Approve and run the command "
|
||||
"Press Enter to confirm or Esc to cancel "
|
||||
" "
|
||||
|
||||
@@ -3,7 +3,14 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"▌Allow command? "
|
||||
"▌ Yes Always No, provide feedback "
|
||||
"▌ Approve and run the command "
|
||||
"▌ Command: echo hello world "
|
||||
"▌ "
|
||||
"▌ Allow command? "
|
||||
"▌ "
|
||||
"▌ > 1. Approve and run now (Y) Run this command one time "
|
||||
"▌ 2. Always approve this session (A) Automatically approve this command for "
|
||||
"▌ the rest of the session "
|
||||
"▌ 3. Cancel (N) Do not run the command "
|
||||
" "
|
||||
"Press Enter to confirm or Esc to cancel "
|
||||
" "
|
||||
|
||||
@@ -3,11 +3,14 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"The model wants to apply changes "
|
||||
"▌ The model wants to apply changes "
|
||||
"▌ "
|
||||
"▌ Grant write access to /tmp for the remainder of this session. "
|
||||
"▌ "
|
||||
"▌ Apply changes? "
|
||||
"▌ "
|
||||
"▌ > 1. Approve (Y) Apply the proposed changes "
|
||||
"▌ 2. Cancel (N) Do not apply the changes "
|
||||
" "
|
||||
"This will grant write access to /tmp for the remainder of this session. "
|
||||
" "
|
||||
"▌Apply changes? "
|
||||
"▌ Yes No, provide feedback "
|
||||
"▌ Approve and apply the changes "
|
||||
"Press Enter to confirm or Esc to cancel "
|
||||
" "
|
||||
|
||||
@@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"this is a test reason such as one that would be produced by the model "
|
||||
"▌ this is a test reason such as one that would be produced by the model "
|
||||
"▌ "
|
||||
"▌ Command: echo 'hello world' "
|
||||
"▌ "
|
||||
"▌ Allow command? "
|
||||
"▌ "
|
||||
"▌ > 1. Approve and run now (Y) Run this command one time "
|
||||
"▌ 2. Always approve this session (A) Automatically approve this command for "
|
||||
"▌ the rest of the session "
|
||||
"▌ 3. Cancel (N) Do not run the command "
|
||||
" "
|
||||
"▌Allow command? "
|
||||
"▌ Yes Always No, provide feedback "
|
||||
"▌ Approve and run the command "
|
||||
"Press Enter to confirm or Esc to cancel "
|
||||
" "
|
||||
|
||||
@@ -66,7 +66,6 @@ mod streaming;
|
||||
mod text_formatting;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
mod user_approval_widget;
|
||||
mod version;
|
||||
mod wrapping;
|
||||
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
//! 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;
|
||||
use crate::history_cell;
|
||||
use crate::text_formatting::truncate_text;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
pub(crate) enum ApprovalRequest {
|
||||
Exec {
|
||||
id: String,
|
||||
command: Vec<String>,
|
||||
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, provide feedback".into()]),
|
||||
description: "Do not run the command; provide feedback",
|
||||
key: KeyCode::Char('n'),
|
||||
decision: ReviewDecision::Abort,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
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, provide feedback".into()]),
|
||||
description: "Do not apply the changes; provide feedback",
|
||||
key: KeyCode::Char('n'),
|
||||
decision: ReviewDecision::Abort,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
/// A modal prompting the user to approve or deny the pending request.
|
||||
pub(crate) struct UserApprovalWidget {
|
||||
approval_request: ApprovalRequest,
|
||||
app_event_tx: AppEventSender,
|
||||
confirmation_prompt: Paragraph<'static>,
|
||||
select_options: &'static 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 { reason, .. } => {
|
||||
let mut contents: Vec<Line> = vec![];
|
||||
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) {
|
||||
match &self.approval_request {
|
||||
ApprovalRequest::Exec { command, .. } => {
|
||||
let full_cmd = strip_bash_lc_and_escape(command);
|
||||
// Construct a concise, single-line summary of the command:
|
||||
// - If multi-line, take the first line and append " ...".
|
||||
// - Truncate to 80 graphemes.
|
||||
let mut snippet = match full_cmd.split_once('\n') {
|
||||
Some((first, _)) => format!("{first} ..."),
|
||||
None => full_cmd.clone(),
|
||||
};
|
||||
// Enforce the 80 character length limit.
|
||||
snippet = truncate_text(&snippet, 80);
|
||||
|
||||
let mut result_spans: Vec<Span<'static>> = Vec::new();
|
||||
match decision {
|
||||
ReviewDecision::Approved => {
|
||||
result_spans.extend(vec![
|
||||
"✔ ".fg(Color::Green),
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet.dim(),
|
||||
" this time".bold(),
|
||||
]);
|
||||
}
|
||||
ReviewDecision::ApprovedForSession => {
|
||||
result_spans.extend(vec![
|
||||
"✔ ".fg(Color::Green),
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet.dim(),
|
||||
" every time this session".bold(),
|
||||
]);
|
||||
}
|
||||
ReviewDecision::Denied => {
|
||||
result_spans.extend(vec![
|
||||
"✗ ".fg(Color::Red),
|
||||
"You ".into(),
|
||||
"did not approve".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet.dim(),
|
||||
]);
|
||||
}
|
||||
ReviewDecision::Abort => {
|
||||
result_spans.extend(vec![
|
||||
"✗ ".fg(Color::Red),
|
||||
"You ".into(),
|
||||
"canceled".bold(),
|
||||
" the request to run ".into(),
|
||||
snippet.dim(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![Line::from(result_spans)];
|
||||
|
||||
if !feedback.trim().is_empty() {
|
||||
lines.push(Line::from("feedback:"));
|
||||
for l in feedback.lines() {
|
||||
lines.push(Line::from(l.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_user_approval_decision(lines),
|
||||
)));
|
||||
}
|
||||
ApprovalRequest::ApplyPatch { .. } => {
|
||||
// No history line for patch approval decisions.
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Reserve space for:
|
||||
// - 1 title line ("Allow command?" or "Apply changes?")
|
||||
// - 1 buttons line (options rendered horizontally on a single row)
|
||||
// - 1 description line (context for the currently selected option)
|
||||
self.get_confirmation_prompt_height(width) + 3
|
||||
}
|
||||
}
|
||||
|
||||
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().add_modifier(Modifier::DIM)
|
||||
};
|
||||
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().add_modifier(Modifier::DIM))
|
||||
.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 tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
#[test]
|
||||
fn lowercase_shortcut_is_accepted() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let req = ApprovalRequest::Exec {
|
||||
id: "1".to_string(),
|
||||
command: vec!["echo".to_string()],
|
||||
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 mut events: Vec<AppEvent> = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
events.push(ev);
|
||||
}
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
AppEvent::CodexOp(Op::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
..
|
||||
})
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_shortcut_is_accepted() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let req = ApprovalRequest::Exec {
|
||||
id: "2".to_string(),
|
||||
command: vec!["echo".to_string()],
|
||||
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 mut events: Vec<AppEvent> = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
events.push(ev);
|
||||
}
|
||||
assert!(events.iter().any(|e| matches!(
|
||||
e,
|
||||
AppEvent::CodexOp(Op::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
..
|
||||
})
|
||||
)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user