mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/terminal): require approval for manual commands
This commit is contained in:
+10
-3
@@ -1025,15 +1025,17 @@ impl App<'_> {
|
||||
}
|
||||
AppEvent::OpenTerminal(launch) => {
|
||||
let mut spawn = None;
|
||||
let requires_immediate_command = !launch.command.is_empty();
|
||||
let restricted = !matches!(self.config.sandbox_policy, SandboxPolicy::DangerFullAccess);
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
if !matches!(self.config.sandbox_policy, SandboxPolicy::DangerFullAccess) {
|
||||
if restricted && requires_immediate_command {
|
||||
widget.history_push(history_cell::new_error_event(
|
||||
"Terminal requires danger-full-access sandbox to run install commands.".to_string(),
|
||||
"Terminal requires Full Access to auto-run install commands.".to_string(),
|
||||
));
|
||||
widget.show_agents_overview_ui();
|
||||
} else {
|
||||
widget.terminal_open(&launch);
|
||||
if !launch.command.is_empty() {
|
||||
if requires_immediate_command {
|
||||
spawn = Some((
|
||||
launch.id,
|
||||
launch.command.clone(),
|
||||
@@ -1141,6 +1143,11 @@ impl App<'_> {
|
||||
}
|
||||
self.terminal_runs.remove(&id);
|
||||
}
|
||||
AppEvent::TerminalApprovalDecision { id, approved } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_terminal_approval_decision(id, approved);
|
||||
}
|
||||
}
|
||||
AppEvent::TerminalAfter(after) => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.handle_terminal_after(after);
|
||||
|
||||
@@ -265,6 +265,7 @@ pub(crate) enum AppEvent {
|
||||
suggestion: String,
|
||||
ack: Redacted<StdSender<TerminalCommandGate>>,
|
||||
},
|
||||
TerminalApprovalDecision { id: u64, approved: bool },
|
||||
#[cfg(not(debug_assertions))]
|
||||
RunUpdateCommand {
|
||||
command: Vec<String>,
|
||||
|
||||
@@ -55,6 +55,7 @@ use self::history_render::HistoryRenderState;
|
||||
use codex_core::parse_command::ParsedCommand;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::ApprovedCommandMatchKind;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
use codex_core::protocol::AgentReasoningEvent;
|
||||
@@ -279,6 +280,7 @@ pub(crate) struct ChatWidget<'a> {
|
||||
|
||||
// Terminal overlay state
|
||||
terminal: TerminalState,
|
||||
pending_manual_terminal: HashMap<u64, PendingManualTerminal>,
|
||||
|
||||
// Persisted selection for Agents overview
|
||||
agents_overview_selected_index: usize,
|
||||
@@ -2070,6 +2072,7 @@ impl ChatWidget<'_> {
|
||||
body_visible_rows: std::cell::Cell::new(0),
|
||||
},
|
||||
terminal: TerminalState::default(),
|
||||
pending_manual_terminal: HashMap::new(),
|
||||
agents_overview_selected_index: 0,
|
||||
#[cfg(not(debug_assertions))]
|
||||
pending_upgrade_notice: None,
|
||||
@@ -2258,6 +2261,7 @@ impl ChatWidget<'_> {
|
||||
body_visible_rows: std::cell::Cell::new(0),
|
||||
},
|
||||
terminal: TerminalState::default(),
|
||||
pending_manual_terminal: HashMap::new(),
|
||||
agents_overview_selected_index: 0,
|
||||
#[cfg(not(debug_assertions))]
|
||||
pending_upgrade_notice: None,
|
||||
@@ -5627,14 +5631,45 @@ impl ChatWidget<'_> {
|
||||
overlay.cancel_pending_command();
|
||||
}
|
||||
|
||||
if !matches!(self.config.sandbox_policy, SandboxPolicy::DangerFullAccess) {
|
||||
self.pending_manual_terminal.insert(
|
||||
id,
|
||||
PendingManualTerminal {
|
||||
command: trimmed.to_string(),
|
||||
},
|
||||
);
|
||||
if let Some(overlay) = self.terminal.overlay_mut() {
|
||||
overlay.push_assistant_message("Awaiting approval to run this command…");
|
||||
overlay.running = false;
|
||||
}
|
||||
self.bottom_pane.push_approval_request(ApprovalRequest::TerminalCommand {
|
||||
id,
|
||||
command: trimmed.to_string(),
|
||||
});
|
||||
self.request_redraw();
|
||||
return;
|
||||
}
|
||||
|
||||
self.start_manual_terminal_session(id, trimmed.to_string());
|
||||
}
|
||||
|
||||
fn start_manual_terminal_session(&mut self, id: u64, command: String) {
|
||||
if command.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(overlay) = self.terminal.overlay_mut() {
|
||||
overlay.cancel_pending_command();
|
||||
overlay.running = true;
|
||||
overlay.exit_code = None;
|
||||
overlay.duration = None;
|
||||
}
|
||||
let (controller_tx, controller_rx) = mpsc::channel();
|
||||
let controller = TerminalRunController { tx: controller_tx };
|
||||
|
||||
let cwd = self.config.cwd.to_string_lossy().to_string();
|
||||
start_direct_terminal_session(
|
||||
self.app_event_tx.clone(),
|
||||
id,
|
||||
trimmed.to_string(),
|
||||
command,
|
||||
Some(cwd),
|
||||
controller,
|
||||
controller_rx,
|
||||
@@ -5731,10 +5766,44 @@ impl ChatWidget<'_> {
|
||||
reset
|
||||
}
|
||||
|
||||
pub(crate) fn handle_terminal_approval_decision(&mut self, id: u64, approved: bool) {
|
||||
let pending = self.pending_manual_terminal.remove(&id);
|
||||
if approved {
|
||||
if let Some(entry) = pending {
|
||||
if self
|
||||
.terminal
|
||||
.overlay()
|
||||
.map(|overlay| overlay.id == id)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(overlay) = self.terminal.overlay_mut() {
|
||||
overlay.push_assistant_message("Approval granted. Running command…");
|
||||
}
|
||||
self.start_manual_terminal_session(id, entry.command);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(entry) = pending {
|
||||
if let Some(overlay) = self.terminal.overlay_mut() {
|
||||
overlay.push_info_message("Command was not approved. You can edit it and try again.");
|
||||
overlay.running = false;
|
||||
overlay.exit_code = None;
|
||||
overlay.duration = None;
|
||||
overlay.pending_command = Some(PendingCommand::manual_with_input(entry.command));
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn close_terminal_overlay(&mut self) {
|
||||
let mut cancel_id = None;
|
||||
let mut preserved_visible = None;
|
||||
let mut overlay_id = None;
|
||||
if let Some(overlay) = self.terminal.overlay_mut() {
|
||||
overlay_id = Some(overlay.id);
|
||||
if overlay.running {
|
||||
cancel_id = Some(overlay.id);
|
||||
}
|
||||
@@ -5744,6 +5813,9 @@ impl ChatWidget<'_> {
|
||||
if let Some(id) = cancel_id {
|
||||
self.app_event_tx.send(AppEvent::TerminalCancel { id });
|
||||
}
|
||||
if let Some(id) = overlay_id {
|
||||
self.pending_manual_terminal.remove(&id);
|
||||
}
|
||||
if let Some(visible_rows) = preserved_visible {
|
||||
self.terminal.last_visible_rows.set(visible_rows);
|
||||
}
|
||||
@@ -14164,6 +14236,10 @@ impl TerminalState {
|
||||
|
||||
const TERMINAL_MAX_LINES: usize = 10_000;
|
||||
|
||||
struct PendingManualTerminal {
|
||||
command: String,
|
||||
}
|
||||
|
||||
struct TerminalOverlay {
|
||||
id: u64,
|
||||
title: String,
|
||||
@@ -14214,6 +14290,14 @@ impl PendingCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_with_input(input: String) -> Self {
|
||||
Self {
|
||||
cursor: input.len(),
|
||||
input,
|
||||
ack: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn action_after_enter(mut self) -> Option<PendingCommandAction> {
|
||||
let command = self.input.trim().to_string();
|
||||
if command.is_empty() {
|
||||
|
||||
@@ -42,6 +42,10 @@ pub(crate) enum ApprovalRequest {
|
||||
reason: Option<String>,
|
||||
grant_root: Option<PathBuf>,
|
||||
},
|
||||
TerminalCommand {
|
||||
id: u64,
|
||||
command: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -124,11 +128,27 @@ impl UserApprovalWidget<'_> {
|
||||
|
||||
Paragraph::new(contents).wrap(Wrap { trim: false })
|
||||
}
|
||||
ApprovalRequest::TerminalCommand { command, .. } => {
|
||||
let mut cmd_span: Span = format!("$ {command}").into();
|
||||
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
|
||||
let contents = vec![
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
"? ".fg(crate::colors::info()),
|
||||
"Run shell command ".bold(),
|
||||
cmd_span,
|
||||
" now?".into(),
|
||||
]),
|
||||
Line::from(""),
|
||||
];
|
||||
Paragraph::new(contents).wrap(Wrap { trim: false })
|
||||
}
|
||||
};
|
||||
|
||||
let select_options = match &approval_request {
|
||||
ApprovalRequest::Exec { command, .. } => build_exec_select_options(command),
|
||||
ApprovalRequest::ApplyPatch { .. } => build_patch_select_options(),
|
||||
ApprovalRequest::TerminalCommand { .. } => build_terminal_select_options(),
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -225,6 +245,14 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
|
||||
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
|
||||
if let ApprovalRequest::TerminalCommand { id, .. } = &self.approval_request {
|
||||
let approved = matches!(decision, ReviewDecision::Approved | ReviewDecision::ApprovedForSession);
|
||||
self.app_event_tx
|
||||
.send(AppEvent::TerminalApprovalDecision { id: *id, approved });
|
||||
self.done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit a background event instead of an assistant message.
|
||||
let message = match &self.approval_request {
|
||||
ApprovalRequest::Exec { command, .. } => {
|
||||
@@ -239,6 +267,7 @@ impl UserApprovalWidget<'_> {
|
||||
ApprovalRequest::ApplyPatch { .. } => {
|
||||
format!("patch approval decision: {:?}", decision)
|
||||
}
|
||||
ApprovalRequest::TerminalCommand { .. } => unreachable!("terminal approvals handled earlier"),
|
||||
};
|
||||
let message = if feedback.trim().is_empty() {
|
||||
message
|
||||
@@ -278,6 +307,7 @@ impl UserApprovalWidget<'_> {
|
||||
id: id.clone(),
|
||||
decision,
|
||||
},
|
||||
ApprovalRequest::TerminalCommand { .. } => unreachable!("terminal approvals handled earlier"),
|
||||
};
|
||||
|
||||
self.app_event_tx.send(AppEvent::CodexOp(op));
|
||||
@@ -442,6 +472,23 @@ fn build_patch_select_options() -> Vec<SelectOption> {
|
||||
]
|
||||
}
|
||||
|
||||
fn build_terminal_select_options() -> Vec<SelectOption> {
|
||||
vec![
|
||||
SelectOption {
|
||||
label: "Yes".to_string(),
|
||||
description: "Approve and run the command".to_string(),
|
||||
hotkey: KeyCode::Char('y'),
|
||||
action: SelectAction::ApproveOnce,
|
||||
},
|
||||
SelectOption {
|
||||
label: "No".to_string(),
|
||||
description: "Dismiss without running the command".to_string(),
|
||||
hotkey: KeyCode::Char('n'),
|
||||
action: SelectAction::Abort,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn normalized_command_tokens(command: &[String]) -> Option<Vec<String>> {
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
|
||||
Reference in New Issue
Block a user