improvements, danger widget

This commit is contained in:
easong-openai
2025-08-02 23:05:13 -07:00
parent a983c1f6ad
commit 8a61d397d4
9 changed files with 669 additions and 156 deletions

View File

@@ -1,6 +1,9 @@
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::command_utils::strip_surrounding_quotes;
use crate::danger_warning_screen::DangerWarningOutcome;
use crate::danger_warning_screen::DangerWarningScreen;
use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::git_warning_screen::GitWarningOutcome;
@@ -16,8 +19,12 @@ use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::execute as ct_execute;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Offset;
use ratatui::layout::Rect;
use ratatui::prelude::Backend;
use ratatui::text::Line;
use std::path::PathBuf;
@@ -43,29 +50,19 @@ enum AppState<'a> {
},
/// The start-up warning that recommends running codex inside a Git repo.
GitWarning { screen: GitWarningScreen },
/// Fullscreen warning when switching to the fullyunsafe execution mode.
DangerWarning {
screen: DangerWarningScreen,
/// Retain the chat widget so background events can still be processed.
widget: Box<ChatWidget<'a>>,
pending_approval: codex_core::protocol::AskForApproval,
pending_sandbox: codex_core::protocol::SandboxPolicy,
},
}
/// Strip a single pair of surrounding quotes from the provided string if present.
/// Supports straight and common curly quotes: '…', "…", ‘…’, “…”.
pub fn strip_surrounding_quotes(s: &str) -> &str {
// Opening/closing pairs (note curly quotes differ on each side)
const QUOTE_PAIRS: &[(char, char)] = &[('"', '"'), ('\'', '\''), ('“', '”'), ('', '')];
let t = s.trim();
if t.len() < 2 {
return t;
}
for &(open, close) in QUOTE_PAIRS {
if t.starts_with(open) && t.ends_with(close) {
let start = open.len_utf8();
let end = t.len() - close.len_utf8();
return &t[start..end];
}
}
t
}
// strip_surrounding_quotes moved to command_utils.rs
pub(crate) struct App<'a> {
app_event_tx: AppEventSender,
@@ -87,6 +84,13 @@ pub(crate) struct App<'a> {
chat_args: Option<ChatWidgetArgs>,
enhanced_keys_supported: bool,
/// One-shot flag to resync viewport and cursor after leaving the
/// alternate-screen Danger warning so the composer stays at the bottom.
fixup_viewport_after_danger: bool,
/// If set, defer opening the DangerWarning screen until after the next
/// redraw so any selection popups are cleared from the normal screen.
pending_show_danger: Option<(codex_core::protocol::AskForApproval, codex_core::protocol::SandboxPolicy)>,
last_bottom_pane_area: Option<Rect>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -102,6 +106,34 @@ struct ChatWidgetArgs {
}
impl App<'_> {
/// Handle `/model <arg>` from the slash command dispatcher.
fn handle_model_command(&mut self, args: &str) {
let arg = args.trim();
if let AppState::Chat { widget } = &mut self.app_state {
let normalized = strip_surrounding_quotes(arg).trim().to_string();
if !normalized.is_empty() {
widget.update_model_and_reconfigure(normalized);
}
}
}
/// Handle `/approvals <preset>` from the slash command dispatcher.
fn handle_approvals_command(&mut self, args: &str) {
let arg = args.trim();
if let AppState::Chat { widget } = &mut self.app_state {
let normalized = strip_surrounding_quotes(arg).trim().to_string();
if !normalized.is_empty() {
use crate::command_utils::parse_execution_mode_token;
if let Some((approval, sandbox)) = parse_execution_mode_token(&normalized) {
widget.update_execution_mode_and_reconfigure(approval, sandbox);
} else {
widget.add_diff_output(format!(
"`/approvals {normalized}` — unrecognized execution mode"
));
}
}
}
}
pub(crate) fn new(
config: Config,
initial_prompt: Option<String>,
@@ -202,6 +234,9 @@ impl App<'_> {
pending_redraw,
chat_args,
enhanced_keys_supported,
fixup_viewport_after_danger: false,
pending_show_danger: None,
last_bottom_pane_area: None,
}
}
@@ -249,6 +284,31 @@ impl App<'_> {
}
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
if let Some((approval, sandbox)) = self.pending_show_danger.take() {
if let Some(area) = self.last_bottom_pane_area {
use crossterm::cursor::MoveTo;
use crossterm::terminal::{Clear, ClearType};
use crossterm::queue;
use std::io::Write;
for y in area.y..area.bottom() {
let _ = queue!(std::io::stdout(), MoveTo(0, y), Clear(ClearType::CurrentLine));
}
let _ = std::io::stdout().flush();
}
if let AppState::Chat { widget } = std::mem::replace(
&mut self.app_state,
AppState::GitWarning { screen: GitWarningScreen::new() },
) {
let _ = ct_execute!(std::io::stdout(), EnterAlternateScreen);
self.app_state = AppState::DangerWarning {
screen: DangerWarningScreen::new(),
widget,
pending_approval: approval,
pending_sandbox: sandbox,
};
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
}
AppEvent::KeyEvent(key_event) => {
match key_event {
@@ -265,6 +325,9 @@ impl App<'_> {
AppState::GitWarning { .. } => {
// No-op.
}
AppState::DangerWarning { .. } => {
// No-op on Ctrl+C in danger screen
}
}
}
KeyEvent {
@@ -287,6 +350,9 @@ impl App<'_> {
AppState::GitWarning { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
AppState::DangerWarning { .. } => {
// Ignore Ctrl+D while danger screen is visible.
}
}
}
KeyEvent {
@@ -315,8 +381,24 @@ impl App<'_> {
}
}
AppEvent::SelectExecutionMode { approval, sandbox } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.update_execution_mode_and_reconfigure(approval, sandbox);
// Intercept the dangerous preset with a fullscreen warning.
if let AppState::Chat { widget: _ } = &self.app_state {
if crate::command_utils::ExecutionPreset::from_policies(approval, &sandbox)
== Some(crate::command_utils::ExecutionPreset::FullYolo)
{
// Defer opening the danger screen until after the next redraw so the
// selection popup is closed and the normal screen is clean.
self.pending_show_danger = Some((approval, sandbox));
self.app_event_tx.send(AppEvent::RequestRedraw);
} else if let AppState::Chat { widget } = std::mem::replace(
&mut self.app_state,
AppState::GitWarning { screen: GitWarningScreen::new() },
) {
// Restore chat state and apply immediately for safe presets.
let mut w = widget;
w.update_execution_mode_and_reconfigure(approval, sandbox);
self.app_state = AppState::Chat { widget: w };
}
}
}
AppEvent::OpenModelSelector => {
@@ -332,10 +414,12 @@ impl App<'_> {
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::GitWarning { .. } => {}
AppState::DangerWarning { widget, .. } => widget.submit_op(op),
},
AppEvent::LatestLog(line) => match &mut self.app_state {
AppState::Chat { widget } => widget.update_latest_log(line),
AppState::GitWarning { .. } => {}
AppState::DangerWarning { widget, .. } => widget.update_latest_log(line),
},
AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => {
@@ -345,15 +429,11 @@ impl App<'_> {
None,
Vec::new(),
self.enhanced_keys_supported,
self
.chat_args
self.chat_args
.as_ref()
.map(|a| a.cli_flags_used.clone())
.unwrap_or_default(),
self
.chat_args
.as_ref()
.and_then(|a| a.cli_model.clone()),
self.chat_args.as_ref().and_then(|a| a.cli_model.clone()),
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
@@ -435,45 +515,16 @@ impl App<'_> {
}
},
AppEvent::DispatchCommandWithArgs(command, args) => match command {
SlashCommand::Model => {
let arg = args.trim();
if let AppState::Chat { widget } = &mut self.app_state {
// Normalize commonly quoted inputs like \"o3\" or 'o3' or “o3”.
let normalized = strip_surrounding_quotes(arg).trim().to_string();
if !normalized.is_empty() {
widget.update_model_and_reconfigure(normalized);
}
}
}
SlashCommand::Approvals => {
let arg = args.trim();
if let AppState::Chat { widget } = &mut self.app_state {
let normalized = strip_surrounding_quotes(arg).trim().to_string();
if !normalized.is_empty() {
use crate::bottom_pane::selection_popup::parse_execution_mode_token;
if let Some((approval, sandbox)) =
parse_execution_mode_token(&normalized)
{
widget.update_execution_mode_and_reconfigure(approval, sandbox);
} else {
widget.add_diff_output(format!(
"`/approvals {normalized}` — unrecognized execution mode"
));
}
}
}
}
SlashCommand::Model => self.handle_model_command(&args),
SlashCommand::Approvals => self.handle_approvals_command(&args),
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
// Ignore args; forward to the existing no-args handler
self.app_event_tx.send(AppEvent::DispatchCommand(command));
}
SlashCommand::New
| SlashCommand::Quit
| SlashCommand::Diff
| SlashCommand::Compact => {
// For other commands, fall back to existing handling.
// We can ignore args for now.
self.app_event_tx.send(AppEvent::DispatchCommand(command));
}
},
@@ -496,13 +547,14 @@ impl App<'_> {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
AppState::DangerWarning { widget, .. } => widget.token_usage().clone(),
}
}
fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> {
let screen_size = terminal.size()?;
let last_known_screen_size = terminal.last_known_screen_size;
if screen_size != last_known_screen_size {
if screen_size != last_known_screen_size && !self.fixup_viewport_after_danger {
let cursor_pos = terminal.get_cursor_position()?;
let last_known_cursor_pos = terminal.last_known_cursor_pos;
if cursor_pos.y != last_known_cursor_pos.y {
@@ -524,8 +576,28 @@ impl App<'_> {
let desired_height = match &self.app_state {
AppState::Chat { widget } => widget.desired_height(size.width),
AppState::GitWarning { .. } => 10,
AppState::DangerWarning { .. } => size.height,
};
// If we just left the alternate-screen danger modal, refresh our internal
// cursor position and anchor the viewport to the bottom so the input bar
// isn't pushed to the top. Additionally, scroll the region above the new
// viewport down to eliminate any stale bottom-pane artifacts without
// clearing the chat history.
if self.fixup_viewport_after_danger {
self.fixup_viewport_after_danger = false;
let pos = terminal.get_cursor_position()?;
terminal.last_known_cursor_pos = pos;
let old_area = terminal.viewport_area;
let mut new_area = old_area;
new_area.height = desired_height.min(size.height);
new_area.width = size.width;
new_area.y = size.height.saturating_sub(new_area.height);
if new_area != old_area {
terminal.set_viewport_area(new_area);
}
}
let mut area = terminal.viewport_area;
area.height = desired_height.min(size.height);
area.width = size.width;
@@ -549,9 +621,15 @@ impl App<'_> {
match &mut self.app_state {
AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
self.last_bottom_pane_area = Some(area);
}
AppState::GitWarning { screen } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
self.last_bottom_pane_area = None;
}
AppState::DangerWarning { screen, .. } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
self.last_bottom_pane_area = None;
}
}
Ok(())
@@ -591,6 +669,50 @@ impl App<'_> {
// do nothing
}
},
AppState::DangerWarning { screen, .. } => match screen.handle_key_event(key_event) {
DangerWarningOutcome::Continue => {
let taken = std::mem::replace(
&mut self.app_state,
AppState::GitWarning {
screen: GitWarningScreen::new(),
},
);
let _ = ct_execute!(std::io::stdout(), LeaveAlternateScreen);
// After leaving the alternate screen, resync our viewport/cursor
// so the chat composer stays anchored at the bottom.
self.fixup_viewport_after_danger = true;
if let AppState::DangerWarning {
mut widget,
pending_approval,
pending_sandbox,
..
} = taken
{
let approval = pending_approval;
let sandbox = pending_sandbox;
widget.update_execution_mode_and_reconfigure(approval, sandbox);
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
DangerWarningOutcome::Cancel => {
let taken = std::mem::replace(
&mut self.app_state,
AppState::GitWarning {
screen: GitWarningScreen::new(),
},
);
let _ = ct_execute!(std::io::stdout(), LeaveAlternateScreen);
// After leaving the alternate screen, resync our viewport/cursor
// so the chat composer stays anchored at the bottom.
self.fixup_viewport_after_danger = true;
if let AppState::DangerWarning { widget, .. } = taken {
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
DangerWarningOutcome::None => {}
},
}
}
@@ -598,6 +720,7 @@ impl App<'_> {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_paste(pasted),
AppState::GitWarning { .. } => {}
AppState::DangerWarning { .. } => {}
}
}
@@ -605,6 +728,7 @@ impl App<'_> {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_codex_event(event),
AppState::GitWarning { .. } => {}
AppState::DangerWarning { widget, .. } => widget.handle_codex_event(event),
}
}
}

View File

@@ -62,7 +62,10 @@ pub(crate) enum AppEvent {
OpenModelSelector,
/// User selected an execution mode (approval + sandbox) from the dropdown or via /approvals.
SelectExecutionMode { approval: AskForApproval, sandbox: SandboxPolicy },
SelectExecutionMode {
approval: AskForApproval,
sandbox: SandboxPolicy,
},
/// Request the app to open the approval selector (populate options and show popup).
OpenApprovalSelector,

View File

@@ -22,7 +22,7 @@ use super::file_search_popup::FileSearchPopup;
use super::selection_popup::SelectionKind;
use super::selection_popup::SelectionPopup;
use super::selection_popup::SelectionValue;
use super::selection_popup::parse_execution_mode_token;
use crate::command_utils::parse_execution_mode_token;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -316,8 +316,7 @@ impl ChatComposer<'_> {
) {
match &mut self.active_popup {
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Execution => {
*popup =
SelectionPopup::new_execution_modes(current_approval, current_sandbox);
*popup = SelectionPopup::new_execution_modes(current_approval, current_sandbox);
}
_ => {
self.active_popup = ActivePopup::Selection(SelectionPopup::new_execution_modes(
@@ -576,13 +575,10 @@ impl ChatComposer<'_> {
SelectionKind::Execution
if cmd_token == SlashCommand::Approvals.command() =>
{
if let Some((approval, sandbox)) =
parse_execution_mode_token(&args)
if let Some((approval, sandbox)) = parse_execution_mode_token(&args)
{
self.app_event_tx.send(AppEvent::SelectExecutionMode {
approval,
sandbox,
});
self.app_event_tx
.send(AppEvent::SelectExecutionMode { approval, sandbox });
self.textarea.select_all();
self.textarea.cut();
self.pending_pastes.clear();
@@ -1656,6 +1652,54 @@ mod tests {
}
}
// Note: slash command with args is usually handled via the selection popup.
#[test]
fn approvals_selection_full_yolo_emits_select_execution_mode() {
use crate::app_event::AppEvent;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Open the execution selector popup with a benign current mode.
composer.open_execution_selector(
AskForApproval::OnFailure,
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
},
);
// Immediately move selection up once to wrap to the last item (Full yolo), then Enter.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Expect a SelectExecutionMode with DangerFullAccess.
let mut saw = false;
loop {
match rx.try_recv() {
Ok(AppEvent::SelectExecutionMode { approval, sandbox }) => {
assert_eq!(approval, AskForApproval::Never);
assert!(matches!(sandbox, SandboxPolicy::DangerFullAccess));
saw = true;
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(saw, "expected SelectExecutionMode for Full yolo");
}
#[test]
fn test_placeholder_deletion() {
use crossterm::event::KeyCode;

View File

@@ -168,12 +168,9 @@ mod tests {
fn selection_list_query_and_navigation() {
// Build a small list with aliases similar to execution-mode popup.
let items = vec![
SelectionItem::new("a", "Auto".to_string())
.with_aliases(vec!["auto".into()]),
SelectionItem::new("u", "Untrusted".to_string())
.with_aliases(vec!["untrusted".into()]),
SelectionItem::new("r", "Read only".to_string())
.with_aliases(vec!["read-only".into()]),
SelectionItem::new("a", "Auto".to_string()).with_aliases(vec!["auto".into()]),
SelectionItem::new("u", "Untrusted".to_string()).with_aliases(vec!["untrusted".into()]),
SelectionItem::new("r", "Read only".to_string()).with_aliases(vec!["read-only".into()]),
];
let mut list = SelectionList::new(items);

View File

@@ -8,14 +8,21 @@ use super::selection_list::SelectionItem;
use super::selection_list::SelectionList;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use crate::command_utils::ExecutionPreset;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SelectionKind { Model, Execution }
pub(crate) enum SelectionKind {
Model,
Execution,
}
#[derive(Clone)]
pub(crate) enum SelectionValue {
Model(String),
Execution { approval: AskForApproval, sandbox: SandboxPolicy },
Execution {
approval: AskForApproval,
sandbox: SandboxPolicy,
},
}
pub(crate) struct SelectionPopup {
@@ -48,43 +55,18 @@ impl SelectionPopup {
current_approval: AskForApproval,
current_sandbox: &SandboxPolicy,
) -> Self {
fn display_name(approval: AskForApproval, sandbox: &SandboxPolicy) -> &'static str {
match (approval, sandbox) {
(AskForApproval::Never, SandboxPolicy::ReadOnly) => "Read only",
(AskForApproval::OnFailure, SandboxPolicy::ReadOnly) => "Untrusted",
(AskForApproval::OnFailure, SandboxPolicy::WorkspaceWrite { .. }) => "Auto",
_ => "Custom",
}
}
fn description_for(approval: AskForApproval, sandbox: &SandboxPolicy) -> &'static str {
match (approval, sandbox) {
(AskForApproval::Never, SandboxPolicy::ReadOnly) =>
"never prompt; read-only filesystem (flags: --ask-for-approval never --sandbox read-only)",
(AskForApproval::OnFailure, SandboxPolicy::ReadOnly) =>
"ask to retry outside sandbox only on sandbox breach; read-only (flags: --ask-for-approval on-failure --sandbox read-only)",
(AskForApproval::OnFailure, SandboxPolicy::WorkspaceWrite { .. }) =>
"auto in workspace sandbox; ask to retry outside sandbox on breach (flags: --ask-for-approval on-failure --sandbox workspace-write)",
_ => "custom combination",
}
}
let presets: Vec<(AskForApproval, SandboxPolicy)> = vec![
(AskForApproval::Never, SandboxPolicy::ReadOnly),
(AskForApproval::OnFailure, SandboxPolicy::ReadOnly),
(
AskForApproval::OnFailure,
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
},
),
let presets: Vec<ExecutionPreset> = vec![
ExecutionPreset::ReadOnly,
ExecutionPreset::Untrusted,
ExecutionPreset::Auto,
ExecutionPreset::FullYolo,
];
let mut items: Vec<SelectionItem<SelectionValue>> = Vec::new();
for (a, s) in presets.into_iter() {
let name = display_name(a, &s).to_string();
let desc = Some(description_for(a, &s).to_string());
for p in presets.into_iter() {
let (a, s) = p.to_policies();
let name = p.label().to_string();
let desc = Some(p.description().to_string());
let mut item = SelectionItem::new(
SelectionValue::Execution {
approval: a,
@@ -93,18 +75,15 @@ impl SelectionPopup {
name,
)
.with_description(desc);
if a == current_approval
&& matches!(
(&s, current_sandbox),
(SandboxPolicy::ReadOnly, SandboxPolicy::ReadOnly)
| (SandboxPolicy::WorkspaceWrite { .. }, SandboxPolicy::WorkspaceWrite { .. })
)
{
if ExecutionPreset::from_policies(current_approval, current_sandbox) == Some(p) {
item = item.mark_current(true);
}
items.push(item);
}
Self { kind: SelectionKind::Execution, list: SelectionList::new(items) }
Self {
kind: SelectionKind::Execution,
list: SelectionList::new(items),
}
}
pub(crate) fn kind(&self) -> SelectionKind {
@@ -143,40 +122,55 @@ impl WidgetRef for &SelectionPopup {
}
}
/// Parse a free-form token to an execution preset (approval+sandbox).
pub(crate) fn parse_execution_mode_token(
s: &str,
) -> Option<(AskForApproval, SandboxPolicy)> {
let t = s.trim().to_ascii_lowercase();
match t.as_str() {
"read-only" => Some((AskForApproval::Never, SandboxPolicy::ReadOnly)),
"untrusted" => Some((AskForApproval::OnFailure, SandboxPolicy::ReadOnly)),
"auto" => Some((
AskForApproval::OnFailure,
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
},
)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::parse_execution_mode_token as parse;
use crate::command_utils::parse_execution_mode_token as parse;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
#[test]
fn parse_approval_mode_aliases() {
// Only accept the three canonical tokens
assert!(matches!(parse("auto").unwrap(), (AskForApproval::OnFailure, SandboxPolicy::WorkspaceWrite { .. })));
assert_eq!(parse("untrusted"), Some((AskForApproval::OnFailure, SandboxPolicy::ReadOnly)));
assert_eq!(parse("read-only"), Some((AskForApproval::Never, SandboxPolicy::ReadOnly)));
assert!(matches!(
parse("auto").unwrap(),
(
AskForApproval::OnFailure,
SandboxPolicy::WorkspaceWrite { .. }
)
));
assert_eq!(
parse("untrusted"),
Some((AskForApproval::OnFailure, SandboxPolicy::ReadOnly))
);
assert_eq!(
parse("read-only"),
Some((AskForApproval::Never, SandboxPolicy::ReadOnly))
);
// Unknown and case/whitespace handling
assert_eq!(parse("unknown"), None);
assert_eq!(parse(" AUTO ").is_some(), true);
assert!(parse(" AUTO ").is_some());
assert_eq!(
parse("full-yolo"),
Some((AskForApproval::Never, SandboxPolicy::DangerFullAccess))
);
}
#[test]
fn execution_selector_includes_full_yolo() {
// Set a benign current mode; we only care about rows.
let popup = super::SelectionPopup::new_execution_modes(
AskForApproval::OnFailure,
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
},
);
let rows = popup.visible_rows();
let labels: Vec<String> = rows.into_iter().map(|r| r.name).collect();
assert!(
labels.iter().any(|l| l.contains("Full yolo")),
"selector should include 'Full yolo'"
);
}
}

View File

@@ -617,12 +617,9 @@ impl ChatWidget<'_> {
self.config.sandbox_policy = sandbox.clone();
if approval_changed || sandbox_changed {
let label = match (approval, &sandbox) {
(AskForApproval::Never, SandboxPolicy::ReadOnly) => "Read only",
(AskForApproval::OnFailure, SandboxPolicy::ReadOnly) => "Untrusted",
(AskForApproval::OnFailure, SandboxPolicy::WorkspaceWrite { .. }) => "Auto",
_ => "Custom",
};
let label = crate::command_utils::ExecutionPreset::from_policies(approval, &sandbox)
.map(|p| p.label())
.unwrap_or("Custom");
self.add_to_history(HistoryCell::new_background_event(format!(
"Set execution mode to {label}."
)));

View File

@@ -0,0 +1,202 @@
//! Small shared helpers for slash-command argument parsing and execution-mode display.
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
/// Canonical execution presets that combine approval policy and sandbox policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionPreset {
/// never prompt; read-only FS
ReadOnly,
/// ask to retry outside sandbox only on sandbox breach; read-only FS
Untrusted,
/// auto within workspace sandbox; ask to retry outside on breach
Auto,
/// DANGEROUS: disables sandbox and approvals entirely.
FullYolo,
}
impl ExecutionPreset {
pub fn label(self) -> &'static str {
match self {
ExecutionPreset::ReadOnly => "Read only",
ExecutionPreset::Untrusted => "Untrusted",
ExecutionPreset::Auto => "Auto",
ExecutionPreset::FullYolo => "Full yolo",
}
}
pub fn description(self) -> &'static str {
match self {
ExecutionPreset::ReadOnly => {
"never prompt; read-only filesystem (flags: --ask-for-approval never --sandbox read-only)"
}
ExecutionPreset::Untrusted => {
"ask to retry outside sandbox only on sandbox breach; read-only (flags: --ask-for-approval on-failure --sandbox read-only)"
}
ExecutionPreset::Auto => {
"auto in workspace sandbox; ask to retry outside sandbox on breach (flags: --ask-for-approval on-failure --sandbox workspace-write)"
}
ExecutionPreset::FullYolo => {
"DANGEROUS: disables sandbox and approvals; the agent can run any commands with full system access (flags: --dangerously-bypass-approvals-and-sandbox)"
}
}
}
/// Canonical CLI flags string for the preset.
pub fn cli_flags(self) -> &'static str {
match self {
ExecutionPreset::ReadOnly => "--ask-for-approval never --sandbox read-only",
ExecutionPreset::Untrusted => "--ask-for-approval on-failure --sandbox read-only",
ExecutionPreset::Auto => "--ask-for-approval on-failure --sandbox workspace-write",
ExecutionPreset::FullYolo => "--dangerously-bypass-approvals-and-sandbox",
}
}
/// Mapping from preset to policies.
pub fn to_policies(self) -> (AskForApproval, SandboxPolicy) {
match self {
ExecutionPreset::ReadOnly => (AskForApproval::Never, SandboxPolicy::ReadOnly),
ExecutionPreset::Untrusted => (AskForApproval::OnFailure, SandboxPolicy::ReadOnly),
ExecutionPreset::Auto => (
AskForApproval::OnFailure,
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
},
),
ExecutionPreset::FullYolo => (AskForApproval::Never, SandboxPolicy::DangerFullAccess),
}
}
/// Mapping from policies to a known preset.
pub fn from_policies(
approval: AskForApproval,
sandbox: &SandboxPolicy,
) -> Option<ExecutionPreset> {
match (approval, sandbox) {
(AskForApproval::Never, SandboxPolicy::ReadOnly) => Some(ExecutionPreset::ReadOnly),
(AskForApproval::OnFailure, SandboxPolicy::ReadOnly) => {
Some(ExecutionPreset::Untrusted)
}
(AskForApproval::OnFailure, SandboxPolicy::WorkspaceWrite { .. }) => {
Some(ExecutionPreset::Auto)
}
(AskForApproval::Never, SandboxPolicy::DangerFullAccess)
| (AskForApproval::OnFailure, SandboxPolicy::DangerFullAccess) => {
Some(ExecutionPreset::FullYolo)
}
_ => None,
}
}
/// Parse one of the canonical tokens: read-only | untrusted | auto.
pub fn parse_token(s: &str) -> Option<ExecutionPreset> {
let t = s.trim().to_ascii_lowercase();
let t = t.replace(' ', "-");
match t.as_str() {
"read-only" => Some(ExecutionPreset::ReadOnly),
"untrusted" => Some(ExecutionPreset::Untrusted),
"auto" => Some(ExecutionPreset::Auto),
"full-yolo" => Some(ExecutionPreset::FullYolo),
_ => None,
}
}
}
/// Strip a single pair of surrounding quotes from the provided string if present.
/// Supports straight and common curly quotes: '…', "…", ‘…’, “…”.
pub fn strip_surrounding_quotes(s: &str) -> &str {
// Opening/closing pairs (note curly quotes differ on each side)
const QUOTE_PAIRS: &[(char, char)] = &[('\"', '\"'), ('\'', '\''), ('“', '”'), ('', '')];
let t = s.trim();
if t.len() < 2 {
return t;
}
for &(open, close) in QUOTE_PAIRS {
if t.starts_with(open) && t.ends_with(close) {
let start = open.len_utf8();
let end = t.len() - close.len_utf8();
return &t[start..end];
}
}
t
}
/// Normalize a free-form token: trim whitespace and remove a single pair of surrounding quotes.
pub fn normalize_token(s: &str) -> String {
strip_surrounding_quotes(s).trim().to_string()
}
/// Map an (approval, sandbox) pair to a concise preset label used in the UI.
pub fn execution_mode_label(approval: AskForApproval, sandbox: &SandboxPolicy) -> &'static str {
ExecutionPreset::from_policies(approval, sandbox)
.map(|p| p.label())
.unwrap_or("Custom")
}
/// Describe the current execution preset including CLI flag equivalents.
pub fn execution_mode_description(
approval: AskForApproval,
sandbox: &SandboxPolicy,
) -> &'static str {
ExecutionPreset::from_policies(approval, sandbox)
.map(|p| p.description())
.unwrap_or("custom combination")
}
/// Parse a free-form token to an execution preset (approval+sandbox).
pub fn parse_execution_mode_token(s: &str) -> Option<(AskForApproval, SandboxPolicy)> {
ExecutionPreset::parse_token(s).map(|p| p.to_policies())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_quotes_variants() {
assert_eq!(strip_surrounding_quotes("\"o3\""), "o3");
assert_eq!(strip_surrounding_quotes("'o3'"), "o3");
assert_eq!(strip_surrounding_quotes("“o3”"), "o3");
assert_eq!(strip_surrounding_quotes("o3"), "o3");
assert_eq!(strip_surrounding_quotes("o3"), "o3");
assert_eq!(strip_surrounding_quotes(" o3 "), "o3");
}
#[test]
fn parse_execution_mode_aliases() {
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
let parse = parse_execution_mode_token;
assert!(matches!(
parse("auto").unwrap(),
(
AskForApproval::OnFailure,
SandboxPolicy::WorkspaceWrite { .. }
)
));
assert_eq!(
parse("untrusted"),
Some((AskForApproval::OnFailure, SandboxPolicy::ReadOnly))
);
assert_eq!(
parse("read-only"),
Some((AskForApproval::Never, SandboxPolicy::ReadOnly))
);
assert_eq!(
parse("full-yolo"),
Some((AskForApproval::Never, SandboxPolicy::DangerFullAccess))
);
assert_eq!(
parse("Full Yolo"),
Some((AskForApproval::Never, SandboxPolicy::DangerFullAccess))
);
assert_eq!(parse("unknown"), None);
assert!(parse(" AUTO ").is_some());
}
}

View File

@@ -0,0 +1,147 @@
//! Fullscreen warning displayed when the user selects the fullyunsafe
//! execution preset (Full yolo). This screen blocks input until the user
//! explicitly confirms or cancels the action.
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Alignment;
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
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;
const DANGER_TEXT: &str = "You're about to disable both approvals and sandboxing.\n\
This gives the agent full, unrestricted access to your system.\n\
\n\
This can seriously damage your computer. Only proceed if you fully understand the risks.";
pub(crate) enum DangerWarningOutcome {
Continue,
Cancel,
None,
}
pub(crate) struct DangerWarningScreen;
impl DangerWarningScreen {
pub(crate) fn new() -> Self {
Self
}
pub(crate) fn handle_key_event(&self, key_event: KeyEvent) -> DangerWarningOutcome {
match key_event.code {
KeyCode::Char('y') | KeyCode::Char('Y') => DangerWarningOutcome::Continue,
KeyCode::Char('n') | KeyCode::Esc | KeyCode::Char('q') => DangerWarningOutcome::Cancel,
_ => DangerWarningOutcome::None,
}
}
}
impl WidgetRef for &DangerWarningScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
const MIN_WIDTH: u16 = 45;
const MIN_HEIGHT: u16 = 15;
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
let p = Paragraph::new(DANGER_TEXT)
.wrap(Wrap { trim: true })
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Red));
p.render(area, buf);
return;
}
let popup_width = std::cmp::max(MIN_WIDTH, (area.width as f32 * 0.6) as u16);
let popup_height = std::cmp::max(MIN_HEIGHT, (area.height as f32 * 0.3) as u16);
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.title(Span::styled(
"Danger: Full system access",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(inner);
let text_block = Block::default().borders(Borders::ALL);
let text_inner = text_block.inner(chunks[0]);
text_block.render(chunks[0], buf);
let p = Paragraph::new(DANGER_TEXT)
.wrap(Wrap { trim: true })
.alignment(Alignment::Left)
.style(Style::default().fg(Color::Red));
p.render(text_inner, buf);
let action_block = Block::default().borders(Borders::ALL);
let action_inner = action_block.inner(chunks[1]);
action_block.render(chunks[1], buf);
let action_text = Paragraph::new("press 'y' to proceed, 'n' to cancel")
.alignment(Alignment::Center)
.style(Style::default().add_modifier(Modifier::BOLD));
action_text.render(action_inner, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
#[test]
fn keys_map_to_expected_outcomes() {
let screen = DangerWarningScreen::new();
// Continue confirmations
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
DangerWarningOutcome::Continue
));
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)),
DangerWarningOutcome::Continue
));
// Cancellations
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)),
DangerWarningOutcome::Cancel
));
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
DangerWarningOutcome::Cancel
));
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
DangerWarningOutcome::Cancel
));
// Irrelevant key is ignored
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
DangerWarningOutcome::None
));
}
}

View File

@@ -25,7 +25,9 @@ mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
mod command_utils;
mod custom_terminal;
mod danger_warning_screen;
mod exec_command;
mod file_search;
mod get_git_diff;
@@ -229,11 +231,14 @@ fn run_ratatui_app(
let mut cli_flags_used: Vec<String> = Vec::new();
if let Some(ap) = cli.approval_policy {
// kebab-case variants via clap ValueEnum Debug formatting is fine here.
cli_flags_used.push(format!("--ask-for-approval {}", match ap {
codex_common::ApprovalModeCliArg::Untrusted => "untrusted",
codex_common::ApprovalModeCliArg::OnFailure => "on-failure",
codex_common::ApprovalModeCliArg::Never => "never",
}));
cli_flags_used.push(format!(
"--ask-for-approval {}",
match ap {
codex_common::ApprovalModeCliArg::Untrusted => "untrusted",
codex_common::ApprovalModeCliArg::OnFailure => "on-failure",
codex_common::ApprovalModeCliArg::Never => "never",
}
));
}
if let Some(sm) = cli.sandbox_mode {
let mode = match sm {
@@ -241,7 +246,7 @@ fn run_ratatui_app(
codex_common::SandboxModeCliArg::WorkspaceWrite => "workspace-write",
codex_common::SandboxModeCliArg::DangerFullAccess => "danger-full-access",
};
cli_flags_used.push(format!("--sandbox {}", mode));
cli_flags_used.push(format!("--sandbox {mode}"));
}
if cli.full_auto {
cli_flags_used.push("--full-auto".to_string());