feat(tui/terminal): require approval for manual commands

This commit is contained in:
James Peter
2025-09-21 09:34:59 +10:00
parent b415a8c339
commit d9be45a85d
4 changed files with 144 additions and 5 deletions
+10 -3
View File
@@ -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);
+1
View File
@@ -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>,
+86 -2
View File
@@ -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() {
+47
View File
@@ -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;