diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d09e498aa9..8f3d1da878 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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 }, + /// Full‑screen warning when switching to the fully‑unsafe execution mode. + DangerWarning { + screen: DangerWarningScreen, + /// Retain the chat widget so background events can still be processed. + widget: Box>, + 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, 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, } /// Aggregate parameters needed to create a `ChatWidget`, as creation may be @@ -102,6 +106,34 @@ struct ChatWidgetArgs { } impl App<'_> { + /// Handle `/model ` 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 ` 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, @@ -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 full‑screen 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), } } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b2ca2b67f0..3ea2afa0a2 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d63f214fe3..45757f0805 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -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; diff --git a/codex-rs/tui/src/bottom_pane/selection_list.rs b/codex-rs/tui/src/bottom_pane/selection_list.rs index fb728726e6..4fe79391cd 100644 --- a/codex-rs/tui/src/bottom_pane/selection_list.rs +++ b/codex-rs/tui/src/bottom_pane/selection_list.rs @@ -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); diff --git a/codex-rs/tui/src/bottom_pane/selection_popup.rs b/codex-rs/tui/src/bottom_pane/selection_popup.rs index 56fbc96e34..31cf6eccd5 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup.rs @@ -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 = vec![ + ExecutionPreset::ReadOnly, + ExecutionPreset::Untrusted, + ExecutionPreset::Auto, + ExecutionPreset::FullYolo, ]; let mut items: Vec> = 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 = rows.into_iter().map(|r| r.name).collect(); + assert!( + labels.iter().any(|l| l.contains("Full yolo")), + "selector should include 'Full yolo'" + ); } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d3477fad57..5ee304c678 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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}." ))); diff --git a/codex-rs/tui/src/command_utils.rs b/codex-rs/tui/src/command_utils.rs new file mode 100644 index 0000000000..840568ac11 --- /dev/null +++ b/codex-rs/tui/src/command_utils.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/codex-rs/tui/src/danger_warning_screen.rs b/codex-rs/tui/src/danger_warning_screen.rs new file mode 100644 index 0000000000..5a076d92f4 --- /dev/null +++ b/codex-rs/tui/src/danger_warning_screen.rs @@ -0,0 +1,147 @@ +//! Full‑screen warning displayed when the user selects the fully‑unsafe +//! 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 + )); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 79adafd8da..34aca66700 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -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 = 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());