2025-10-01 14:29:05 -07:00
|
|
|
use std::collections::HashMap;
|
2025-09-26 07:13:29 -07:00
|
|
|
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::ListSelectionView;
|
|
|
|
|
use crate::bottom_pane::list_selection_view::SelectionItem;
|
|
|
|
|
use crate::bottom_pane::list_selection_view::SelectionViewParams;
|
2025-10-01 14:29:05 -07:00
|
|
|
use crate::diff_render::DiffSummary;
|
2025-09-26 07:13:29 -07:00
|
|
|
use crate::exec_command::strip_bash_lc_and_escape;
|
|
|
|
|
use crate::history_cell;
|
2025-10-01 14:29:05 -07:00
|
|
|
use crate::render::highlight::highlight_bash_to_lines;
|
|
|
|
|
use crate::render::renderable::ColumnRenderable;
|
|
|
|
|
use crate::render::renderable::Renderable;
|
2025-09-26 07:13:29 -07:00
|
|
|
use crate::text_formatting::truncate_text;
|
2025-10-01 14:29:05 -07:00
|
|
|
use codex_core::protocol::FileChange;
|
2025-09-26 07:13:29 -07:00
|
|
|
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;
|
2025-10-01 14:29:05 -07:00
|
|
|
use ratatui::widgets::Paragraph;
|
|
|
|
|
use ratatui::widgets::Wrap;
|
2025-09-26 07:13:29 -07:00
|
|
|
|
|
|
|
|
/// Request coming from the agent that needs user approval.
|
2025-10-01 14:29:05 -07:00
|
|
|
#[derive(Clone, Debug)]
|
2025-09-26 07:13:29 -07:00
|
|
|
pub(crate) enum ApprovalRequest {
|
|
|
|
|
Exec {
|
|
|
|
|
id: String,
|
|
|
|
|
command: Vec<String>,
|
|
|
|
|
reason: Option<String>,
|
|
|
|
|
},
|
|
|
|
|
ApplyPatch {
|
|
|
|
|
id: String,
|
|
|
|
|
reason: Option<String>,
|
2025-10-01 14:29:05 -07:00
|
|
|
cwd: PathBuf,
|
|
|
|
|
changes: HashMap<PathBuf, FileChange>,
|
2025-09-26 07:13:29 -07:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Modal overlay asking the user to approve or deny one or more requests.
|
|
|
|
|
pub(crate) struct ApprovalOverlay {
|
2025-10-01 14:29:05 -07:00
|
|
|
current_request: Option<ApprovalRequest>,
|
|
|
|
|
current_variant: Option<ApprovalVariant>,
|
2025-09-26 07:13:29 -07:00
|
|
|
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 {
|
2025-10-01 14:29:05 -07:00
|
|
|
current_request: None,
|
|
|
|
|
current_variant: None,
|
2025-09-26 07:13:29 -07:00
|
|
|
queue: Vec::new(),
|
|
|
|
|
app_event_tx: app_event_tx.clone(),
|
2025-10-01 14:29:05 -07:00
|
|
|
list: ListSelectionView::new(Default::default(), app_event_tx),
|
2025-09-26 07:13:29 -07:00
|
|
|
options: Vec::new(),
|
|
|
|
|
current_complete: false,
|
|
|
|
|
done: false,
|
|
|
|
|
};
|
2025-10-01 14:29:05 -07:00
|
|
|
view.set_current(request);
|
2025-09-26 07:13:29 -07:00
|
|
|
view
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn enqueue_request(&mut self, req: ApprovalRequest) {
|
|
|
|
|
self.queue.push(req);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_current(&mut self, request: ApprovalRequest) {
|
2025-10-01 14:29:05 -07:00
|
|
|
self.current_request = Some(request.clone());
|
|
|
|
|
let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request);
|
|
|
|
|
self.current_variant = Some(variant.clone());
|
2025-09-26 07:13:29 -07:00
|
|
|
self.current_complete = false;
|
2025-10-01 14:29:05 -07:00
|
|
|
let (options, params) = Self::build_options(variant, header);
|
2025-09-26 07:13:29 -07:00
|
|
|
self.options = options;
|
|
|
|
|
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 14:29:05 -07:00
|
|
|
fn build_options(
|
|
|
|
|
variant: ApprovalVariant,
|
|
|
|
|
header: Box<dyn Renderable>,
|
|
|
|
|
) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
|
|
|
|
let (options, title) = match &variant {
|
2025-09-26 07:13:29 -07:00
|
|
|
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
|
|
|
|
|
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-01 14:29:05 -07:00
|
|
|
let header = Box::new(ColumnRenderable::new([
|
|
|
|
|
Box::new(Line::from(title.bold())),
|
|
|
|
|
Box::new(Line::from("")),
|
|
|
|
|
header,
|
|
|
|
|
]));
|
|
|
|
|
|
2025-09-26 07:13:29 -07:00
|
|
|
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 {
|
|
|
|
|
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
|
|
|
|
|
items,
|
2025-10-01 14:29:05 -07:00
|
|
|
header,
|
2025-09-26 07:13:29 -07:00
|
|
|
..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;
|
|
|
|
|
};
|
2025-10-01 14:29:05 -07:00
|
|
|
if let Some(variant) = self.current_variant.as_ref() {
|
|
|
|
|
match (&variant, option.decision) {
|
2025-09-26 07:13:29 -07:00
|
|
|
(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 {
|
2025-10-01 14:29:05 -07:00
|
|
|
match key_event {
|
|
|
|
|
KeyEvent {
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
code: KeyCode::Char('a'),
|
|
|
|
|
modifiers,
|
|
|
|
|
..
|
|
|
|
|
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
|
|
|
|
if let Some(request) = self.current_request.as_ref() {
|
|
|
|
|
self.app_event_tx
|
|
|
|
|
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
KeyEvent {
|
|
|
|
|
kind: KeyEventKind::Press,
|
|
|
|
|
code: KeyCode::Char(c),
|
|
|
|
|
modifiers,
|
|
|
|
|
..
|
|
|
|
|
} if !modifiers.contains(KeyModifiers::CONTROL)
|
|
|
|
|
&& !modifiers.contains(KeyModifiers::ALT) =>
|
|
|
|
|
{
|
|
|
|
|
let lower = c.to_ascii_lowercase();
|
|
|
|
|
match self
|
|
|
|
|
.options
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
|
|
|
|
|
{
|
|
|
|
|
Some(idx) => {
|
|
|
|
|
self.apply_selection(idx);
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
None => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => false,
|
2025-09-26 07:13:29 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
2025-10-01 14:29:05 -07:00
|
|
|
&& let Some(variant) = self.current_variant.as_ref()
|
2025-09-26 07:13:29 -07:00
|
|
|
{
|
2025-10-01 14:29:05 -07:00
|
|
|
match &variant {
|
2025-09-26 07:13:29 -07:00
|
|
|
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 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 14:29:05 -07:00
|
|
|
impl Renderable for ApprovalOverlay {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 07:13:29 -07:00
|
|
|
struct ApprovalRequestState {
|
|
|
|
|
variant: ApprovalVariant,
|
2025-10-01 14:29:05 -07:00
|
|
|
header: Box<dyn Renderable>,
|
2025-09-26 07:13:29 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<ApprovalRequest> for ApprovalRequestState {
|
|
|
|
|
fn from(value: ApprovalRequest) -> Self {
|
|
|
|
|
match value {
|
|
|
|
|
ApprovalRequest::Exec {
|
|
|
|
|
id,
|
|
|
|
|
command,
|
|
|
|
|
reason,
|
|
|
|
|
} => {
|
2025-10-01 14:29:05 -07:00
|
|
|
let mut header: Vec<Line<'static>> = Vec::new();
|
2025-09-26 07:13:29 -07:00
|
|
|
if let Some(reason) = reason
|
|
|
|
|
&& !reason.is_empty()
|
|
|
|
|
{
|
2025-10-01 14:29:05 -07:00
|
|
|
header.push(reason.italic().into());
|
|
|
|
|
header.push(Line::from(""));
|
2025-09-26 07:13:29 -07:00
|
|
|
}
|
2025-10-01 14:29:05 -07:00
|
|
|
let full_cmd = strip_bash_lc_and_escape(&command);
|
|
|
|
|
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
|
|
|
|
if let Some(first) = full_cmd_lines.first_mut() {
|
|
|
|
|
first.spans.insert(0, Span::from("$ "));
|
2025-09-26 07:13:29 -07:00
|
|
|
}
|
2025-10-01 14:29:05 -07:00
|
|
|
header.extend(full_cmd_lines);
|
2025-09-26 07:13:29 -07:00
|
|
|
Self {
|
|
|
|
|
variant: ApprovalVariant::Exec { id, command },
|
2025-10-01 14:29:05 -07:00
|
|
|
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
|
2025-09-26 07:13:29 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
ApprovalRequest::ApplyPatch {
|
|
|
|
|
id,
|
|
|
|
|
reason,
|
2025-10-01 14:29:05 -07:00
|
|
|
cwd,
|
|
|
|
|
changes,
|
2025-09-26 07:13:29 -07:00
|
|
|
} => {
|
2025-10-01 14:29:05 -07:00
|
|
|
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
|
|
|
|
|
header.push(DiffSummary::new(changes, cwd).into());
|
2025-09-26 07:13:29 -07:00
|
|
|
if let Some(reason) = reason
|
|
|
|
|
&& !reason.is_empty()
|
|
|
|
|
{
|
2025-10-01 14:29:05 -07:00
|
|
|
header.push(Box::new(Line::from("")));
|
|
|
|
|
header.push(Box::new(
|
|
|
|
|
Paragraph::new(reason.italic()).wrap(Wrap { trim: false }),
|
|
|
|
|
));
|
2025-09-26 07:13:29 -07:00
|
|
|
}
|
|
|
|
|
Self {
|
|
|
|
|
variant: ApprovalVariant::ApplyPatch { id },
|
2025-10-01 14:29:05 -07:00
|
|
|
header: Box::new(ColumnRenderable::new(header)),
|
2025-09-26 07:13:29 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-01 14:29:05 -07:00
|
|
|
#[derive(Clone)]
|
2025-09-26 07:13:29 -07:00
|
|
|
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(),
|
2025-10-01 14:29:05 -07:00
|
|
|
description: "Run this command one time".to_string(),
|
2025-09-26 07:13:29 -07:00
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
shortcut: Some('y'),
|
|
|
|
|
},
|
|
|
|
|
ApprovalOption {
|
|
|
|
|
label: "Always approve this session".to_string(),
|
2025-10-01 14:29:05 -07:00
|
|
|
description: "Automatically approve this command for the rest of the session"
|
2025-09-26 07:13:29 -07:00
|
|
|
.to_string(),
|
|
|
|
|
decision: ReviewDecision::ApprovedForSession,
|
|
|
|
|
shortcut: Some('a'),
|
|
|
|
|
},
|
|
|
|
|
ApprovalOption {
|
|
|
|
|
label: "Cancel".to_string(),
|
2025-10-01 14:29:05 -07:00
|
|
|
description: "Do not run the command".to_string(),
|
2025-09-26 07:13:29 -07:00
|
|
|
decision: ReviewDecision::Abort,
|
|
|
|
|
shortcut: Some('n'),
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn patch_options() -> Vec<ApprovalOption> {
|
|
|
|
|
vec![
|
|
|
|
|
ApprovalOption {
|
|
|
|
|
label: "Approve".to_string(),
|
2025-10-01 14:29:05 -07:00
|
|
|
description: "Apply the proposed changes".to_string(),
|
2025-09-26 07:13:29 -07:00
|
|
|
decision: ReviewDecision::Approved,
|
|
|
|
|
shortcut: Some('y'),
|
|
|
|
|
},
|
|
|
|
|
ApprovalOption {
|
|
|
|
|
label: "Cancel".to_string(),
|
2025-10-01 14:29:05 -07:00
|
|
|
description: "Do not apply the changes".to_string(),
|
2025-09-26 07:13:29 -07:00
|
|
|
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);
|
2025-10-01 14:29:05 -07:00
|
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
|
|
|
|
|
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
|
2025-09-26 07:13:29 -07:00
|
|
|
|
|
|
|
|
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()
|
2025-10-01 14:29:05 -07:00
|
|
|
.any(|line| line.contains("echo hello world")),
|
2025-09-26 07:13:29 -07:00
|
|
|
"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));
|
|
|
|
|
}
|
|
|
|
|
}
|