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 crossterm::event::KeyEvent;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use ratatui::text::Line;
|
|||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
|
use textwrap::wrap;
|
||||||
|
|
||||||
use crate::app_event_sender::AppEventSender;
|
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.
|
/// One selectable item in the generic selection list.
|
||||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
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(crate) struct SelectionItem {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
@@ -39,6 +46,7 @@ pub(crate) struct SelectionViewParams {
|
|||||||
pub items: Vec<SelectionItem>,
|
pub items: Vec<SelectionItem>,
|
||||||
pub is_searchable: bool,
|
pub is_searchable: bool,
|
||||||
pub search_placeholder: Option<String>,
|
pub search_placeholder: Option<String>,
|
||||||
|
pub header: Vec<HeaderLine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct ListSelectionView {
|
pub(crate) struct ListSelectionView {
|
||||||
@@ -53,6 +61,8 @@ pub(crate) struct ListSelectionView {
|
|||||||
search_query: String,
|
search_query: String,
|
||||||
search_placeholder: Option<String>,
|
search_placeholder: Option<String>,
|
||||||
filtered_indices: Vec<usize>,
|
filtered_indices: Vec<usize>,
|
||||||
|
last_selected_actual_idx: Option<usize>,
|
||||||
|
header: Vec<HeaderLine>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListSelectionView {
|
impl ListSelectionView {
|
||||||
@@ -82,6 +92,8 @@ impl ListSelectionView {
|
|||||||
None
|
None
|
||||||
},
|
},
|
||||||
filtered_indices: Vec::new(),
|
filtered_indices: Vec::new(),
|
||||||
|
last_selected_actual_idx: None,
|
||||||
|
header: params.header,
|
||||||
};
|
};
|
||||||
s.apply_filter();
|
s.apply_filter();
|
||||||
s
|
s
|
||||||
@@ -198,6 +210,7 @@ impl ListSelectionView {
|
|||||||
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
&& let Some(actual_idx) = self.filtered_indices.get(idx)
|
||||||
&& let Some(item) = self.items.get(*actual_idx)
|
&& let Some(item) = self.items.get(*actual_idx)
|
||||||
{
|
{
|
||||||
|
self.last_selected_actual_idx = Some(*actual_idx);
|
||||||
for act in &item.actions {
|
for act in &item.actions {
|
||||||
act(&self.app_event_tx);
|
act(&self.app_event_tx);
|
||||||
}
|
}
|
||||||
@@ -214,6 +227,43 @@ impl ListSelectionView {
|
|||||||
self.search_query = query;
|
self.search_query = query;
|
||||||
self.apply_filter();
|
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 {
|
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 the title row, +1 for a spacer line beneath the header,
|
||||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
// +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 {
|
if self.is_searchable {
|
||||||
height = height.saturating_add(1);
|
height = height.saturating_add(1);
|
||||||
}
|
}
|
||||||
@@ -295,20 +346,46 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
return;
|
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 {
|
let title_area = Rect {
|
||||||
x: area.x,
|
x: area.x,
|
||||||
y: area.y,
|
y: next_y,
|
||||||
width: area.width,
|
width: area.width,
|
||||||
height: 1,
|
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>> =
|
if self.is_searchable && next_y < area.y + area.height {
|
||||||
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 {
|
|
||||||
let search_area = Rect {
|
let search_area = Rect {
|
||||||
x: area.x,
|
x: area.x,
|
||||||
y: next_y,
|
y: next_y,
|
||||||
@@ -327,20 +404,25 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
.render(search_area, buf);
|
.render(search_area, buf);
|
||||||
next_y = next_y.saturating_add(1);
|
next_y = next_y.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(sub) = &self.subtitle {
|
if let Some(sub) = &self.subtitle {
|
||||||
|
if next_y >= area.y + area.height {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let subtitle_area = Rect {
|
let subtitle_area = Rect {
|
||||||
x: area.x,
|
x: area.x,
|
||||||
y: next_y,
|
y: next_y,
|
||||||
width: area.width,
|
width: area.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let subtitle_spans: Vec<Span<'static>> =
|
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
|
||||||
vec![Self::dim_prefix_span(), sub.clone().dim()];
|
.render(subtitle_area, buf);
|
||||||
let subtitle_para = Paragraph::new(Line::from(subtitle_spans));
|
|
||||||
subtitle_para.render(subtitle_area, buf);
|
|
||||||
next_y = next_y.saturating_add(1);
|
next_y = next_y.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if next_y >= area.y + area.height {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let spacer_area = Rect {
|
let spacer_area = Rect {
|
||||||
x: area.x,
|
x: area.x,
|
||||||
y: next_y,
|
y: next_y,
|
||||||
@@ -351,6 +433,9 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
next_y = next_y.saturating_add(1);
|
next_y = next_y.saturating_add(1);
|
||||||
|
|
||||||
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
|
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
|
||||||
|
if next_y >= area.y + area.height {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let rows_area = Rect {
|
let rows_area = Rect {
|
||||||
x: area.x,
|
x: area.x,
|
||||||
y: next_y,
|
y: next_y,
|
||||||
@@ -381,8 +466,7 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
width: area.width,
|
width: area.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
};
|
};
|
||||||
let footer_para = Paragraph::new(hint.clone().dim());
|
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
|
||||||
footer_para.render(footer_area, buf);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::tui::FrameRequester;
|
use crate::tui::FrameRequester;
|
||||||
use crate::user_approval_widget::ApprovalRequest;
|
|
||||||
use bottom_pane_view::BottomPaneView;
|
use bottom_pane_view::BottomPaneView;
|
||||||
use codex_core::protocol::TokenUsageInfo;
|
use codex_core::protocol::TokenUsageInfo;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
@@ -16,7 +15,9 @@ use ratatui::layout::Rect;
|
|||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
use std::time::Duration;
|
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 bottom_pane_view;
|
||||||
mod chat_composer;
|
mod chat_composer;
|
||||||
mod chat_composer_history;
|
mod chat_composer_history;
|
||||||
@@ -43,7 +44,6 @@ pub(crate) use chat_composer::InputResult;
|
|||||||
use codex_protocol::custom_prompts::CustomPrompt;
|
use codex_protocol::custom_prompts::CustomPrompt;
|
||||||
|
|
||||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||||
use approval_modal_view::ApprovalModalView;
|
|
||||||
pub(crate) use list_selection_view::SelectionAction;
|
pub(crate) use list_selection_view::SelectionAction;
|
||||||
pub(crate) use list_selection_view::SelectionItem;
|
pub(crate) use list_selection_view::SelectionItem;
|
||||||
|
|
||||||
@@ -397,7 +397,7 @@ impl BottomPane {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Otherwise create a new approval modal overlay.
|
// 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.pause_status_timer_for_modal();
|
||||||
self.push_view(Box::new(modal));
|
self.push_view(Box::new(modal));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ use crate::slash_command::SlashCommand;
|
|||||||
use crate::text_formatting::truncate_text;
|
use crate::text_formatting::truncate_text;
|
||||||
use crate::tui::FrameRequester;
|
use crate::tui::FrameRequester;
|
||||||
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
// streaming internals are provided by crate::streaming and crate::markdown_stream
|
||||||
use crate::user_approval_widget::ApprovalRequest;
|
use crate::bottom_pane::ApprovalRequest;
|
||||||
mod interrupts;
|
mod interrupts;
|
||||||
use self::interrupts::InterruptManager;
|
use self::interrupts::InterruptManager;
|
||||||
mod agent;
|
mod agent;
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs
|
|||||||
expression: terminal.backend()
|
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? "
|
"Press Enter to confirm or Esc to cancel "
|
||||||
"▌ Yes Always No, provide feedback "
|
|
||||||
"▌ Approve and run the command "
|
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ source: tui/src/chatwidget/tests.rs
|
|||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
"▌Allow command? "
|
"▌ Command: echo hello world "
|
||||||
"▌ Yes Always No, provide feedback "
|
"▌ "
|
||||||
"▌ Approve and run the command "
|
"▌ 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()
|
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. "
|
"Press Enter to confirm or Esc to cancel "
|
||||||
" "
|
|
||||||
"▌Apply changes? "
|
|
||||||
"▌ Yes No, provide feedback "
|
|
||||||
"▌ Approve and apply the changes "
|
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ source: tui/src/chatwidget/tests.rs
|
|||||||
expression: terminal.backend()
|
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? "
|
"Press Enter to confirm or Esc to cancel "
|
||||||
"▌ Yes Always No, provide feedback "
|
|
||||||
"▌ Approve and run the command "
|
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ mod streaming;
|
|||||||
mod text_formatting;
|
mod text_formatting;
|
||||||
mod tui;
|
mod tui;
|
||||||
mod ui_consts;
|
mod ui_consts;
|
||||||
mod user_approval_widget;
|
|
||||||
mod version;
|
mod version;
|
||||||
mod wrapping;
|
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