From 01d52f143062c7e97c434fb2d1202681cb1417c3 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Sat, 14 Mar 2026 00:09:56 -0700 Subject: [PATCH] feat(tui): add setup sandbox wizard --- codex-rs/core/src/config/edit.rs | 114 +++++ codex-rs/tui/src/app.rs | 63 +++ codex-rs/tui/src/app_event.rs | 36 ++ .../tui/src/bottom_pane/custom_prompt_view.rs | 42 +- .../src/bottom_pane/list_selection_view.rs | 52 +- codex-rs/tui/src/bottom_pane/mod.rs | 5 +- .../src/bottom_pane/multi_select_picker.rs | 19 + .../tui/src/bottom_pane/slash_commands.rs | 10 + ...side_height_grows_to_fit_tall_preview.snap | 12 + codex-rs/tui/src/chatwidget.rs | 83 +++ codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/lib.rs | 6 + codex-rs/tui/src/setup/config_wizard.rs | 339 +++++++++++++ .../src/setup/config_wizard_recommendation.rs | 347 +++++++++++++ .../config_wizard_recommendation_tests.rs | 190 +++++++ codex-rs/tui/src/setup/config_wizard_state.rs | 472 ++++++++++++++++++ .../src/setup/config_wizard_state_tests.rs | 149 ++++++ ...title_includes_recommendation_summary.snap | 8 + ..._writes_legacy_workspace_write_config.snap | 16 + codex-rs/tui/src/slash_command.rs | 4 + 20 files changed, 1962 insertions(+), 6 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_side_by_side_height_grows_to_fit_tall_preview.snap create mode 100644 codex-rs/tui/src/setup/config_wizard.rs create mode 100644 codex-rs/tui/src/setup/config_wizard_recommendation.rs create mode 100644 codex-rs/tui/src/setup/config_wizard_recommendation_tests.rs create mode 100644 codex-rs/tui/src/setup/config_wizard_state.rs create mode 100644 codex-rs/tui/src/setup/config_wizard_state_tests.rs create mode 100644 codex-rs/tui/src/setup/snapshots/codex_tui__config_wizard_state__tests__access_mode_subtitle_includes_recommendation_summary.snap create mode 100644 codex-rs/tui/src/setup/snapshots/codex_tui__config_wizard_state__tests__preview_toml_writes_legacy_workspace_write_config.snap diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index cf139d3a5e..e55d33db9b 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -3,6 +3,7 @@ use crate::config::types::Notice; use crate::path_utils::resolve_symlink_write_paths; use crate::path_utils::write_atomically; use anyhow::Context; +use anyhow::Result; use codex_config::CONFIG_TOML_FILE; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; @@ -13,6 +14,7 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; use tokio::task; +use toml::Value as TomlValue; use toml_edit::ArrayOfTables; use toml_edit::DocumentMut; use toml_edit::Item as TomlItem; @@ -79,6 +81,13 @@ pub fn status_line_items_edit(items: &[String]) -> ConfigEdit { } } +pub fn set_path_toml_edit(segments: Vec, value: TomlValue) -> Result { + Ok(ConfigEdit::SetPath { + segments, + value: toml_value_to_item(value)?, + }) +} + pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); @@ -294,6 +303,44 @@ mod document_helpers { } } +fn toml_value_to_item(value: TomlValue) -> Result { + match value { + TomlValue::Table(table) => { + let mut table_item = TomlTable::new(); + table_item.set_implicit(false); + for (key, value) in table { + table_item.insert(&key, toml_value_to_item(value)?); + } + Ok(TomlItem::Table(table_item)) + } + value => Ok(TomlItem::Value(toml_value_to_value(value)?)), + } +} + +fn toml_value_to_value(value: TomlValue) -> Result { + match value { + TomlValue::String(value) => Ok(toml_edit::Value::from(value)), + TomlValue::Integer(value) => Ok(toml_edit::Value::from(value)), + TomlValue::Float(value) => Ok(toml_edit::Value::from(value)), + TomlValue::Boolean(value) => Ok(toml_edit::Value::from(value)), + TomlValue::Datetime(value) => Ok(toml_edit::Value::from(value)), + TomlValue::Array(values) => { + let mut array = toml_edit::Array::new(); + for value in values { + array.push(toml_value_to_value(value)?); + } + Ok(toml_edit::Value::Array(array)) + } + TomlValue::Table(table) => { + let mut inline = toml_edit::InlineTable::new(); + for (key, value) in table { + inline.insert(&key, toml_value_to_value(value)?); + } + Ok(toml_edit::Value::InlineTable(inline)) + } + } +} + struct ConfigDocument { doc: DocumentMut, profile: Option, @@ -1003,6 +1050,73 @@ model_reasoning_effort = "high" assert_eq!(contents, "enabled = true\n"); } + #[test] + fn set_path_toml_edit_writes_profile_tables_with_quoted_keys() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + let mut filesystem = toml::map::Map::new(); + filesystem.insert( + "~/.config/pip".to_string(), + TomlValue::String("write".to_string()), + ); + filesystem.insert( + ":minimal".to_string(), + TomlValue::String("read".to_string()), + ); + filesystem.insert( + ":project_roots".to_string(), + TomlValue::Table({ + let mut project_roots = toml::map::Map::new(); + project_roots.insert(".".to_string(), TomlValue::String("write".to_string())); + project_roots + }), + ); + let profile = TomlValue::Table({ + let mut table = toml::map::Map::new(); + table.insert("filesystem".to_string(), TomlValue::Table(filesystem)); + table + }); + + apply_blocking( + codex_home, + None, + &[set_path_toml_edit( + vec!["permissions".to_string(), "project-sandbox".to_string()], + profile, + ) + .expect("build edit")], + ) + .expect("persist"); + + let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let config = toml::from_str::(&raw).expect("parse config"); + let filesystem = config + .get("permissions") + .and_then(TomlValue::as_table) + .and_then(|permissions| permissions.get("project-sandbox")) + .and_then(TomlValue::as_table) + .and_then(|profile| profile.get("filesystem")) + .and_then(TomlValue::as_table) + .expect("filesystem table"); + assert_eq!( + filesystem.get("~/.config/pip").and_then(TomlValue::as_str), + Some("write") + ); + assert_eq!( + filesystem.get(":minimal").and_then(TomlValue::as_str), + Some("read") + ); + assert_eq!( + filesystem + .get(":project_roots") + .and_then(TomlValue::as_table) + .and_then(|roots| roots.get(".")) + .and_then(TomlValue::as_str), + Some("write") + ); + } + #[test] fn set_model_availability_nux_count_writes_shown_count() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e649455da6..ef3c887ed9 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2869,6 +2869,69 @@ impl App { } } } + AppEvent::OpenConfigWizardAccessMode => { + self.chat_widget.open_config_wizard_access_mode(); + } + AppEvent::OpenConfigWizardWorkspaceWriteOptions => { + self.chat_widget + .open_config_wizard_workspace_write_options(); + } + AppEvent::OpenConfigWizardTextStep { step } => { + self.chat_widget.open_config_wizard_text_step(step); + } + AppEvent::ConfigWizardAccessModeSelected { access_mode } => { + self.chat_widget + .update_config_wizard_access_mode(access_mode); + if access_mode == crate::config_wizard::ConfigWizardAccessMode::WorkspaceWrite { + self.chat_widget + .open_config_wizard_workspace_write_options(); + } else { + self.chat_widget.open_config_wizard_summary(); + } + } + AppEvent::ConfigWizardWorkspaceWriteOptionsSelected { selected } => { + self.chat_widget + .update_config_wizard_workspace_write_options(selected); + self.chat_widget.open_config_wizard_text_step( + crate::config_wizard::ConfigWizardTextStep::WritableRoots, + ); + } + AppEvent::ConfigWizardTextSubmitted { step, value } => { + match self.chat_widget.apply_config_wizard_text_step(step, value) { + Ok(()) => match step { + crate::config_wizard::ConfigWizardTextStep::WritableRoots => { + self.chat_widget.open_config_wizard_summary(); + } + }, + Err(err) => { + self.chat_widget + .add_error_message(format!("Invalid sandbox setup input: {err}")); + self.chat_widget.open_config_wizard_text_step(step); + } + } + } + AppEvent::ApplyConfigWizard { request } => { + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(request.edits) + .apply() + .await + { + Ok(()) => { + if let Err(err) = self.refresh_in_memory_config_from_disk().await { + tracing::warn!( + error = %err, + "failed to refresh config after guided config write" + ); + } + self.chat_widget.add_info_message(request.summary, None); + } + Err(err) => { + tracing::error!(error = %err, "failed to persist guided config"); + self.chat_widget + .add_error_message(format!("Failed to save guided config: {err}")); + } + } + } AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => { let builder = match kind { RealtimeAudioDeviceKind::Microphone => { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9f9a8d6de1..e033b60650 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -20,6 +20,10 @@ use codex_utils_approval_presets::ApprovalPreset; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; +use crate::config_wizard::ConfigWizardAccessMode; +use crate::config_wizard::ConfigWizardApplyRequest; +use crate::config_wizard::ConfigWizardTextStep; +use crate::config_wizard::ConfigWizardWorkspaceWriteOption; use crate::history_cell::HistoryCell; use codex_core::features::Feature; @@ -203,6 +207,38 @@ pub(crate) enum AppEvent { service_tier: Option, }, + /// Re-open the access-mode step of the guided config wizard. + OpenConfigWizardAccessMode, + + /// Re-open the workspace-write options step of the guided config wizard. + OpenConfigWizardWorkspaceWriteOptions, + + /// Re-open a text-entry step of the guided config wizard. + OpenConfigWizardTextStep { + step: ConfigWizardTextStep, + }, + + /// Advance the config wizard with the selected access mode. + ConfigWizardAccessModeSelected { + access_mode: ConfigWizardAccessMode, + }, + + /// Advance the config wizard with the selected workspace-write options. + ConfigWizardWorkspaceWriteOptionsSelected { + selected: Vec, + }, + + /// Advance the config wizard with text input from the current prompt step. + ConfigWizardTextSubmitted { + step: ConfigWizardTextStep, + value: String, + }, + + /// Write the config generated by the guided wizard. + ApplyConfigWizard { + request: ConfigWizardApplyRequest, + }, + /// Open the device picker for a realtime microphone or speaker. OpenRealtimeAudioDeviceSelection { kind: RealtimeAudioDeviceKind, diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index e9f0ee697f..e3aa87bf66 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -23,6 +23,8 @@ use super::textarea::TextAreaState; /// Callback invoked when the user submits a custom prompt. pub(crate) type PromptSubmitted = Box; +/// Callback invoked when the user cancels a custom prompt. +pub(crate) type PromptCancelled = Box; /// Minimal multi-line text input view to collect custom review instructions. pub(crate) struct CustomPromptView { @@ -30,6 +32,9 @@ pub(crate) struct CustomPromptView { placeholder: String, context_label: Option, on_submit: PromptSubmitted, + on_cancel: Option, + submit_empty_input: bool, + left_arrow_cancels_at_start: bool, // UI state textarea: TextArea, @@ -49,16 +54,48 @@ impl CustomPromptView { placeholder, context_label, on_submit, + on_cancel: None, + submit_empty_input: false, + left_arrow_cancels_at_start: false, textarea: TextArea::new(), textarea_state: RefCell::new(TextAreaState::default()), complete: false, } } + + pub(crate) fn with_cancel_handler(mut self, on_cancel: PromptCancelled) -> Self { + self.on_cancel = Some(on_cancel); + self + } + + pub(crate) fn with_initial_text(mut self, text: String) -> Self { + if !text.is_empty() { + self.textarea.insert_str(&text); + } + self + } + + pub(crate) fn submit_empty_input(mut self) -> Self { + self.submit_empty_input = true; + self + } + + pub(crate) fn left_arrow_cancels_at_start(mut self) -> Self { + self.left_arrow_cancels_at_start = true; + self + } } impl BottomPaneView for CustomPromptView { fn handle_key_event(&mut self, key_event: KeyEvent) { match key_event { + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } if self.left_arrow_cancels_at_start && self.textarea.cursor() == 0 => { + self.on_ctrl_c(); + } KeyEvent { code: KeyCode::Esc, .. } => { @@ -70,7 +107,7 @@ impl BottomPaneView for CustomPromptView { .. } => { let text = self.textarea.text().trim().to_string(); - if !text.is_empty() { + if !text.is_empty() || self.submit_empty_input { (self.on_submit)(text); self.complete = true; } @@ -88,6 +125,9 @@ impl BottomPaneView for CustomPromptView { } fn on_ctrl_c(&mut self) -> CancellationEvent { + if let Some(on_cancel) = self.on_cancel.as_ref() { + on_cancel(); + } self.complete = true; CancellationEvent::Handled } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 9a50a07831..7654d4b7ab 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -173,6 +173,9 @@ pub(crate) struct SelectionViewParams { /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. pub on_cancel: OnCancelCallback, + + /// Treat Left Arrow as back/cancel. + pub left_arrow_cancels: bool, } impl Default for SelectionViewParams { @@ -196,6 +199,7 @@ impl Default for SelectionViewParams { preserve_side_content_bg: false, on_selection_changed: None, on_cancel: None, + left_arrow_cancels: false, } } } @@ -232,6 +236,9 @@ pub(crate) struct ListSelectionView { /// Called when the picker is dismissed via Esc/Ctrl+C without selecting. on_cancel: OnCancelCallback, + + /// Treat Left Arrow as back/cancel. + left_arrow_cancels: bool, } impl ListSelectionView { @@ -280,6 +287,7 @@ impl ListSelectionView { preserve_side_content_bg: params.preserve_side_content_bg, on_selection_changed: params.on_selection_changed, on_cancel: params.on_cancel, + left_arrow_cancels: params.left_arrow_cancels, }; s.apply_filter(); s @@ -622,6 +630,13 @@ impl BottomPaneView for ListSelectionView { self.search_query.pop(); self.apply_filter(); } + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } if self.left_arrow_cancels => { + self.on_ctrl_c(); + } KeyEvent { code: KeyCode::Esc, .. } => { @@ -730,10 +745,10 @@ impl Renderable for ListSelectionView { } // Side content: when the terminal is wide enough the panel sits beside - // the list and shares vertical space; otherwise it stacks below. - if self.side_layout_width(inner_width).is_some() { - // Side-by-side — side content shares list rows vertically so it - // doesn't add to total height. + // the list, but the popup still needs enough vertical space to fit + // whichever side is taller. + if let Some(side_w) = self.side_layout_width(inner_width) { + height = height.max(self.side_content.desired_height(side_w)); } else { let side_h = self.stacked_side_content().desired_height(inner_width); if side_h > 0 { @@ -1787,6 +1802,35 @@ mod tests { ); } + #[test] + fn side_by_side_height_grows_to_fit_tall_preview() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Debug".to_string()), + items: vec![SelectionItem { + name: "Item 1".to_string(), + dismiss_on_select: true, + ..Default::default() + }], + side_content: Box::new(MarkerRenderable { + marker: "W", + height: 8, + }), + side_content_width: SideContentWidth::Half, + side_content_min_width: 10, + ..Default::default() + }, + tx, + ); + + assert_snapshot!( + "list_selection_side_by_side_height_grows_to_fit_tall_preview", + render_lines_with_width(&view, 120) + ); + } + #[test] fn side_content_clearing_handles_non_zero_buffer_origin() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 62142ed227..dd88272fa2 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -26,7 +26,7 @@ use crate::render::renderable::FlexRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; -use bottom_pane_view::BottomPaneView; +pub(crate) use bottom_pane_view::BottomPaneView; use codex_core::features::Features; use codex_core::plugins::PluginCapabilitySummary; use codex_core::skills::model::SkillMetadata; @@ -92,6 +92,9 @@ pub(crate) use feedback_view::FeedbackAudience; pub(crate) use feedback_view::feedback_disabled_params; pub(crate) use feedback_view::feedback_selection_params; pub(crate) use feedback_view::feedback_upload_consent_params; +pub(crate) use list_selection_view::ListSelectionView; +pub(crate) use multi_select_picker::MultiSelectItem; +pub(crate) use multi_select_picker::MultiSelectPicker; pub(crate) use skills_toggle_view::SkillsToggleItem; pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; diff --git a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs index a8acf18668..90816f6797 100644 --- a/codex-rs/tui/src/bottom_pane/multi_select_picker.rs +++ b/codex-rs/tui/src/bottom_pane/multi_select_picker.rs @@ -157,6 +157,9 @@ pub(crate) struct MultiSelectPicker { /// Callback invoked when the user cancels the picker. on_cancel: Option, + + /// Treat Left Arrow as back/cancel when ordering is disabled. + left_arrow_cancels: bool, } impl MultiSelectPicker { @@ -418,6 +421,13 @@ impl BottomPaneView for MultiSelectPicker { KeyEvent { code: KeyCode::Left, .. } if self.ordering_enabled => { self.move_selected_item(Direction::Up); } + KeyEvent { + code: KeyCode::Left, + modifiers: KeyModifiers::NONE, + .. + } if self.left_arrow_cancels => { + self.close(); + } KeyEvent { code: KeyCode::Right, .. } if self.ordering_enabled => { self.move_selected_item(Direction::Down); } @@ -628,6 +638,7 @@ pub(crate) struct MultiSelectPickerBuilder { on_change: Option, on_confirm: Option, on_cancel: Option, + left_arrow_cancels: bool, } impl MultiSelectPickerBuilder { @@ -644,6 +655,7 @@ impl MultiSelectPickerBuilder { on_change: None, on_confirm: None, on_cancel: None, + left_arrow_cancels: false, } } @@ -714,6 +726,12 @@ impl MultiSelectPickerBuilder { self } + /// Treat Left Arrow as back/cancel when ordering is disabled. + pub fn left_arrow_cancels(mut self) -> Self { + self.left_arrow_cancels = true; + self + } + /// Builds the [`MultiSelectPicker`] with all configured options. /// /// Initializes the filter to show all items and generates the initial @@ -755,6 +773,7 @@ impl MultiSelectPickerBuilder { on_change: self.on_change, on_confirm: self.on_confirm, on_cancel: self.on_cancel, + left_arrow_cancels: self.left_arrow_cancels, }; view.apply_filter(); view.update_preview_line(); diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 85b301386b..d63a002b78 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -82,6 +82,16 @@ mod tests { ); } + #[test] + fn setup_sandbox_command_resolves_without_config_alias() { + let flags = all_enabled_flags(); + assert_eq!( + find_builtin_command("setup-sandbox", flags), + Some(SlashCommand::SetupSandbox) + ); + assert_eq!(find_builtin_command("config", flags), None); + } + #[test] fn fast_command_is_hidden_when_disabled() { let mut flags = all_enabled_flags(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_side_by_side_height_grows_to_fit_tall_preview.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_side_by_side_height_grows_to_fit_tall_preview.snap new file mode 100644 index 0000000000..23dfba0623 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_side_by_side_height_grows_to_fit_tall_preview.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/list_selection_view.rs +assertion_line: 1828 +expression: "render_lines_with_width(&view, 120)" +--- + + Debug WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW +› 1. Item 1 WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW + WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9e9d750b4a..99c1f5f3d8 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -44,6 +44,9 @@ use crate::audio_device::list_realtime_audio_device_names; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::StatusLinePreviewData; use crate::bottom_pane::StatusLineSetupView; +use crate::config_wizard::ConfigWizardAccessMode; +use crate::config_wizard::ConfigWizardState; +use crate::config_wizard::ConfigWizardTextStep; use crate::status::RateLimitWindowDisplay; use crate::status::format_directory_display; use crate::status::format_tokens_compact; @@ -552,6 +555,7 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, + config_wizard: Option, /// The unmasked collaboration mode settings (always Default mode). /// /// Masks are applied on top of this base mode to derive the effective mode. @@ -3118,6 +3122,7 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, + config_wizard: None, skills_all: Vec::new(), skills_initial_state: None, current_collaboration_mode, @@ -3300,6 +3305,7 @@ impl ChatWidget { active_cell, active_cell_revision: 0, config, + config_wizard: None, skills_all: Vec::new(), skills_initial_state: None, current_collaboration_mode, @@ -3474,6 +3480,7 @@ impl ChatWidget { active_cell: None, active_cell_revision: 0, config, + config_wizard: None, skills_all: Vec::new(), skills_initial_state: None, current_collaboration_mode, @@ -3928,6 +3935,9 @@ impl ChatWidget { SlashCommand::Permissions => { self.open_permissions_popup(); } + SlashCommand::SetupSandbox => { + self.open_config_wizard(); + } SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] { @@ -6631,6 +6641,79 @@ impl ChatWidget { }); } + pub(crate) fn open_config_wizard(&mut self) { + self.config_wizard = Some(ConfigWizardState::detect_with_config(&self.config)); + self.open_config_wizard_access_mode(); + } + + pub(crate) fn open_config_wizard_access_mode(&mut self) { + if self.config_wizard.is_none() { + self.config_wizard = Some(ConfigWizardState::detect_with_config(&self.config)); + } + let Some(state) = self.config_wizard.as_ref() else { + return; + }; + let view = state.access_mode_view(self.app_event_tx.clone()); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(crate) fn update_config_wizard_access_mode(&mut self, access_mode: ConfigWizardAccessMode) { + if let Some(state) = self.config_wizard.as_mut() { + state.access_mode = access_mode; + } + } + + pub(crate) fn open_config_wizard_workspace_write_options(&mut self) { + let Some(state) = self.config_wizard.as_ref() else { + self.open_config_wizard(); + return; + }; + self.bottom_pane.show_view(Box::new( + state.workspace_write_options_picker(self.app_event_tx.clone()), + )); + } + + pub(crate) fn update_config_wizard_workspace_write_options( + &mut self, + selected: Vec, + ) { + if let Some(state) = self.config_wizard.as_mut() { + state.set_workspace_write_options(&selected); + } + } + + pub(crate) fn open_config_wizard_text_step(&mut self, step: ConfigWizardTextStep) { + let Some(state) = self.config_wizard.as_ref() else { + self.open_config_wizard(); + return; + }; + self.bottom_pane.show_view(Box::new( + state.text_step_view(step, self.app_event_tx.clone()), + )); + } + + pub(crate) fn apply_config_wizard_text_step( + &mut self, + step: ConfigWizardTextStep, + value: String, + ) -> anyhow::Result<()> { + if let Some(state) = self.config_wizard.as_mut() { + state.apply_text_step(step, value)?; + } + Ok(()) + } + + pub(crate) fn open_config_wizard_summary(&mut self) { + let Some(state) = self.config_wizard.as_ref() else { + self.open_config_wizard(); + return; + }; + match state.summary_view(self.app_event_tx.clone()) { + Ok(view) => self.bottom_pane.show_view(Box::new(view)), + Err(err) => self.add_error_message(format!("Failed to build config preview: {err}")), + } + } + pub(crate) fn open_experimental_popup(&mut self) { let features: Vec = FEATURES .iter() diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 7307891106..4bc9c6bed7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1895,6 +1895,7 @@ async fn make_chatwidget_manual( external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), last_rendered_user_message_event: None, + config_wizard: None, }; widget.set_model(&resolved_model); (widget, rx, op_rx) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 43e0e2afe9..e6b6ee5b6a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -72,6 +72,12 @@ mod clipboard_paste; mod clipboard_text; mod collaboration_modes; mod color; +#[path = "setup/config_wizard.rs"] +mod config_wizard; +#[path = "setup/config_wizard_recommendation.rs"] +mod config_wizard_recommendation; +#[path = "setup/config_wizard_state.rs"] +mod config_wizard_state; pub mod custom_terminal; mod cwd_prompt; mod debug_config; diff --git a/codex-rs/tui/src/setup/config_wizard.rs b/codex-rs/tui/src/setup/config_wizard.rs new file mode 100644 index 0000000000..31cae39a4a --- /dev/null +++ b/codex-rs/tui/src/setup/config_wizard.rs @@ -0,0 +1,339 @@ +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ListSelectionView; +use crate::bottom_pane::MultiSelectItem; +use crate::bottom_pane::MultiSelectPicker; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::SideContentWidth; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::render::renderable::ColumnRenderable; +use anyhow::Result; +use ratatui::style::Stylize; +use ratatui::text::Line; + +pub(crate) use crate::config_wizard_state::ConfigWizardAccessMode; +pub(crate) use crate::config_wizard_state::ConfigWizardApplyRequest; +pub(crate) use crate::config_wizard_state::ConfigWizardState; +pub(crate) use crate::config_wizard_state::ConfigWizardTextStep; +pub(crate) use crate::config_wizard_state::ConfigWizardWorkspaceWriteOption; + +impl ConfigWizardState { + pub(crate) fn access_mode_view(&self, app_event_tx: AppEventSender) -> ListSelectionView { + let items = [ + ConfigWizardAccessMode::ReadOnly, + ConfigWizardAccessMode::WorkspaceWrite, + ConfigWizardAccessMode::FullAccess, + ] + .into_iter() + .map(|access_mode| SelectionItem { + name: access_mode.label().to_string(), + description: Some(access_mode.description().to_string()), + is_current: self.access_mode == access_mode, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ConfigWizardAccessModeSelected { access_mode }); + })], + dismiss_on_select: true, + ..Default::default() + }) + .collect(); + + ListSelectionView::new( + SelectionViewParams { + title: Some("Sandbox Setup: Access".to_string()), + subtitle: Some(self.access_mode_subtitle()), + footer_hint: Some(standard_popup_hint_line()), + items, + side_content: preview_renderable(self), + side_content_width: SideContentWidth::Half, + side_content_min_width: 36, + ..Default::default() + }, + app_event_tx, + ) + } + + pub(crate) fn workspace_write_options_picker( + &self, + app_event_tx: AppEventSender, + ) -> MultiSelectPicker { + let selected_options = self.selected_workspace_write_options(); + MultiSelectPicker::builder( + "Sandbox Setup: Workspace-Write Options".to_string(), + Some(self.workspace_write_subtitle()), + app_event_tx, + ) + .items( + ConfigWizardWorkspaceWriteOption::ALL + .into_iter() + .map(|option| MultiSelectItem { + id: option.key().to_string(), + name: option.label().to_string(), + description: Some(option.description().to_string()), + enabled: selected_options.contains(&option), + }) + .collect(), + ) + .on_preview(|items| Some(Line::from(selection_preview(items, "Enabled")))) + .on_confirm(|selected_ids: &[String], tx: &AppEventSender| { + let selected = selected_ids + .iter() + .filter_map(|id| ConfigWizardWorkspaceWriteOption::from_key(id)) + .collect(); + tx.send(AppEvent::ConfigWizardWorkspaceWriteOptionsSelected { selected }); + }) + .on_cancel(|tx: &AppEventSender| { + tx.send(AppEvent::OpenConfigWizardAccessMode); + }) + .left_arrow_cancels() + .build() + } + + pub(crate) fn text_step_view( + &self, + step: ConfigWizardTextStep, + app_event_tx: AppEventSender, + ) -> CustomPromptView { + let submit_tx = app_event_tx.clone(); + let mut view = CustomPromptView::new( + ConfigWizardState::prompt_title(step).to_string(), + ConfigWizardState::prompt_placeholder(step).to_string(), + self.prompt_context(step), + Box::new(move |value: String| { + submit_tx.send(AppEvent::ConfigWizardTextSubmitted { step, value }); + }), + ) + .with_initial_text(self.prompt_initial_value(step).unwrap_or_default()) + .submit_empty_input() + .left_arrow_cancels_at_start(); + + if Self::back_event_for_text_step(step).is_some() { + let cancel_tx = app_event_tx; + let cancel_step = step; + view = view.with_cancel_handler(Box::new(move || { + if let Some(event) = Self::back_event_for_text_step(cancel_step) { + cancel_tx.send(event); + } + })); + } + view + } + + pub(crate) fn summary_view(&self, app_event_tx: AppEventSender) -> Result { + let apply_request = self.build_apply_request()?; + let uses_workspace_write = self.uses_workspace_write(); + let back_item = if self.uses_workspace_write() { + SelectionItem { + name: "Back to directories".to_string(), + description: Some("Adjust the directories you want Codex to edit.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenConfigWizardTextStep { + step: ConfigWizardTextStep::WritableRoots, + }); + })], + dismiss_on_select: true, + ..Default::default() + } + } else { + SelectionItem { + name: "Back to access mode".to_string(), + description: Some("Choose a different sandbox mode.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenConfigWizardAccessMode); + })], + dismiss_on_select: true, + ..Default::default() + } + }; + let items = vec![ + SelectionItem { + name: "Apply sandbox settings".to_string(), + description: Some("Save these sandbox settings to config.toml.".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ApplyConfigWizard { + request: apply_request.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + back_item, + ]; + + Ok(ListSelectionView::new( + SelectionViewParams { + title: Some("Sandbox Setup: Summary".to_string()), + subtitle: Some("Review the sandbox settings before applying them.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + side_content: preview_renderable(self), + side_content_width: SideContentWidth::Half, + side_content_min_width: 36, + on_cancel: Some(Box::new(move |tx| { + tx.send(Self::summary_back_event(uses_workspace_write)); + })), + left_arrow_cancels: true, + ..Default::default() + }, + app_event_tx, + )) + } + + fn back_event_for_text_step(step: ConfigWizardTextStep) -> Option { + match step { + ConfigWizardTextStep::WritableRoots => { + Some(AppEvent::OpenConfigWizardWorkspaceWriteOptions) + } + } + } + + fn summary_back_event(uses_workspace_write: bool) -> AppEvent { + if uses_workspace_write { + AppEvent::OpenConfigWizardTextStep { + step: ConfigWizardTextStep::WritableRoots, + } + } else { + AppEvent::OpenConfigWizardAccessMode + } + } +} + +fn selection_preview(items: &[MultiSelectItem], prefix: &str) -> String { + let selected = items + .iter() + .filter(|item| item.enabled) + .map(|item| item.name.as_str()) + .collect::>(); + if selected.is_empty() { + format!("{prefix}: none") + } else { + format!("{prefix}: {}", selected.join(", ")) + } +} + +fn preview_renderable(state: &ConfigWizardState) -> Box { + let mut renderable = ColumnRenderable::new(); + renderable.push("Config preview:".bold()); + renderable.push(""); + for line in state.preview_toml().lines() { + renderable.push(Line::from(line.to_string())); + } + Box::new(renderable) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::BottomPaneView; + use codex_utils_absolute_path::AbsolutePathBuf; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc::UnboundedReceiver; + use tokio::sync::mpsc::unbounded_channel; + + fn test_sender() -> (AppEventSender, UnboundedReceiver) { + let (tx, rx) = unbounded_channel(); + (AppEventSender::new(tx), rx) + } + + fn test_state() -> ConfigWizardState { + ConfigWizardState::test_state() + } + + #[test] + fn writable_roots_enter_allows_empty_submission() { + let mut state = test_state(); + state.writable_roots.clear(); + let (tx, mut rx) = test_sender(); + let mut view = state.text_step_view(ConfigWizardTextStep::WritableRoots, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!( + view.is_complete(), + "expected writable-roots prompt to complete" + ); + match rx + .try_recv() + .expect("expected config wizard submission event") + { + AppEvent::ConfigWizardTextSubmitted { step, value } => { + assert_eq!(step, ConfigWizardTextStep::WritableRoots); + assert!( + value.is_empty(), + "expected empty submission to preserve defaults" + ); + } + event => panic!("unexpected app event: {event:?}"), + } + } + + #[test] + fn writable_roots_submits_prefilled_recommendations() { + let mut state = test_state(); + state.access_mode = ConfigWizardAccessMode::WorkspaceWrite; + state.writable_roots = vec![ + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/openai") + .expect("absolute path"), + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/infra").expect("absolute path"), + ]; + state.network_access = true; + let (tx, mut rx) = test_sender(); + let mut view = state.text_step_view(ConfigWizardTextStep::WritableRoots, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + match rx + .try_recv() + .expect("expected config wizard submission event") + { + AppEvent::ConfigWizardTextSubmitted { step, value } => { + assert_eq!(step, ConfigWizardTextStep::WritableRoots); + assert_eq!(value, "/Users/viyatb/code/openai\n/Users/viyatb/code/infra"); + } + event => panic!("unexpected app event: {event:?}"), + } + } + + #[test] + fn writable_roots_escape_reopens_workspace_write_options() { + let mut state = test_state(); + state.writable_roots.clear(); + let (tx, mut rx) = test_sender(); + let mut view = state.text_step_view(ConfigWizardTextStep::WritableRoots, tx); + + let cancellation = view.on_ctrl_c(); + + assert_eq!(cancellation, crate::bottom_pane::CancellationEvent::Handled); + assert!( + view.is_complete(), + "expected writable-roots prompt to complete on escape" + ); + match rx.try_recv().expect("expected wizard back event") { + AppEvent::OpenConfigWizardWorkspaceWriteOptions => {} + event => panic!("unexpected app event: {event:?}"), + } + } + + #[test] + fn workspace_write_options_left_arrow_reopens_access_mode() { + let state = test_state(); + let (tx, mut rx) = test_sender(); + let mut view = state.workspace_write_options_picker(tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + + assert!( + view.is_complete(), + "expected left arrow to close workspace-write options" + ); + match rx.try_recv().expect("expected wizard back event") { + AppEvent::OpenConfigWizardAccessMode => {} + event => panic!("unexpected app event: {event:?}"), + } + } +} diff --git a/codex-rs/tui/src/setup/config_wizard_recommendation.rs b/codex-rs/tui/src/setup/config_wizard_recommendation.rs new file mode 100644 index 0000000000..af5bf0a087 --- /dev/null +++ b/codex-rs/tui/src/setup/config_wizard_recommendation.rs @@ -0,0 +1,347 @@ +use codex_core::INTERACTIVE_SESSION_SOURCES; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::fs; +use std::fs::File; +use std::io::BufRead; +use std::io::BufReader; +use std::path::Path; +use std::path::PathBuf; + +const HEAD_LINES_TO_SCAN: usize = 40; +const MAX_RECENT_INTERACTIVE_SESSIONS: usize = 40; +const MAX_RECOMMENDED_DIRECTORIES: usize = 4; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ConfigWizardRecommendation { + pub(crate) access_mode: SandboxMode, + pub(crate) network_access: bool, + pub(crate) directories: Vec, + pub(crate) sampled_sessions: usize, + pub(crate) workspace_write_sessions: usize, + pub(crate) workspace_write_network_sessions: usize, +} + +impl ConfigWizardRecommendation { + pub(crate) fn summary(&self) -> String { + let mut parts = vec![format!( + "Recommended from {} recent interactive sessions: {}", + self.sampled_sessions, + match self.access_mode { + SandboxMode::ReadOnly => "read-only access", + SandboxMode::WorkspaceWrite => "workspace-write access", + SandboxMode::DangerFullAccess => "full access", + } + )]; + + if self.workspace_write_sessions > 0 { + parts.push(format!( + "network {} in {}/{} workspace-write sessions", + if self.network_access { + "enabled" + } else { + "disabled" + }, + self.workspace_write_network_sessions, + self.workspace_write_sessions + )); + } + + if !self.directories.is_empty() { + let dirs = self + .directories + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + parts.push(format!("common directories: {dirs}")); + } + + format!("{}.", parts.join("; ")) + } +} + +#[derive(Clone, Debug)] +struct SessionRecommendationSample { + cwd: PathBuf, + sandbox_policy: SandboxPolicy, +} + +pub(crate) fn detect_config_wizard_recommendation_for_codex_home( + codex_home: &Path, + current_cwd: Option<&Path>, +) -> Option { + detect_config_wizard_recommendation_in_root(codex_home.join("sessions"), current_cwd) +} + +fn detect_config_wizard_recommendation_in_root( + sessions_root: PathBuf, + current_cwd: Option<&Path>, +) -> Option { + if !sessions_root.exists() { + return None; + } + + let codex_home = sessions_root.parent(); + let rollout_paths = newest_rollout_paths(&sessions_root, MAX_RECENT_INTERACTIVE_SESSIONS * 4); + if rollout_paths.is_empty() { + return None; + } + + let mut samples = Vec::new(); + for rollout_path in rollout_paths { + let Some(sample) = read_session_sample(&rollout_path) else { + continue; + }; + samples.push(sample); + if samples.len() == MAX_RECENT_INTERACTIVE_SESSIONS { + break; + } + } + + if samples.is_empty() { + return None; + } + + let workspace_write_sessions = samples + .iter() + .filter(|sample| matches!(sample.sandbox_policy, SandboxPolicy::WorkspaceWrite { .. })) + .count(); + let workspace_write_network_sessions = samples + .iter() + .filter(|sample| { + matches!(sample.sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) + && sample.sandbox_policy.has_full_network_access() + }) + .count(); + + Some(ConfigWizardRecommendation { + access_mode: recommended_access_mode(&samples), + network_access: workspace_write_sessions > 0 + && workspace_write_network_sessions * 2 >= workspace_write_sessions, + directories: recommended_directories(&samples, current_cwd, codex_home), + sampled_sessions: samples.len(), + workspace_write_sessions, + workspace_write_network_sessions, + }) +} + +fn recommended_access_mode(samples: &[SessionRecommendationSample]) -> SandboxMode { + let mut read_only_count = 0usize; + let mut workspace_write_count = 0usize; + let mut full_access_count = 0usize; + + for sample in samples { + match sample.sandbox_policy { + SandboxPolicy::DangerFullAccess => full_access_count += 1, + SandboxPolicy::ReadOnly { .. } => read_only_count += 1, + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::ExternalSandbox { .. } => { + workspace_write_count += 1; + } + } + } + + [ + (SandboxMode::WorkspaceWrite, workspace_write_count), + (SandboxMode::ReadOnly, read_only_count), + (SandboxMode::DangerFullAccess, full_access_count), + ] + .into_iter() + .max_by_key(|(mode, count)| (*count, access_mode_priority(*mode))) + .map(|(mode, _)| mode) + .unwrap_or(SandboxMode::WorkspaceWrite) +} + +fn access_mode_priority(mode: SandboxMode) -> u8 { + match mode { + SandboxMode::WorkspaceWrite => 3, + SandboxMode::ReadOnly => 2, + SandboxMode::DangerFullAccess => 1, + } +} + +fn recommended_directories( + samples: &[SessionRecommendationSample], + current_cwd: Option<&Path>, + codex_home: Option<&Path>, +) -> Vec { + let mut counts: Vec<(AbsolutePathBuf, usize)> = Vec::new(); + + for sample in samples { + for directory in sample.recommended_directories(current_cwd, codex_home) { + if let Some((_, count)) = counts.iter_mut().find(|(path, _)| *path == directory) { + *count += 1; + } else { + counts.push((directory, 1)); + } + } + } + + counts.sort_by(|(left_path, left_count), (right_path, right_count)| { + right_count.cmp(left_count).then_with(|| { + left_path + .display() + .to_string() + .cmp(&right_path.display().to_string()) + }) + }); + + counts + .into_iter() + .map(|(path, _)| path) + .take(MAX_RECOMMENDED_DIRECTORIES) + .collect() +} + +impl SessionRecommendationSample { + fn recommended_directories( + &self, + current_cwd: Option<&Path>, + codex_home: Option<&Path>, + ) -> Vec { + let mut directories = Vec::new(); + push_recommended_directory(&mut directories, &self.cwd, current_cwd, codex_home); + + if let SandboxPolicy::WorkspaceWrite { .. } = &self.sandbox_policy { + for writable_root in self.sandbox_policy.get_writable_roots_with_cwd(&self.cwd) { + push_recommended_directory( + &mut directories, + writable_root.root.as_path(), + current_cwd, + codex_home, + ); + } + } + + directories + } +} + +fn push_recommended_directory( + directories: &mut Vec, + candidate: &Path, + current_cwd: Option<&Path>, + codex_home: Option<&Path>, +) { + if !candidate.is_absolute() + || current_cwd.is_some_and(|cwd| cwd == candidate) + || is_temp_directory(candidate) + || is_codex_internal_directory(candidate, codex_home) + { + return; + } + + let Ok(candidate) = AbsolutePathBuf::try_from(candidate.to_path_buf()) else { + return; + }; + if !directories.contains(&candidate) { + directories.push(candidate); + } +} + +fn is_temp_directory(path: &Path) -> bool { + path.starts_with(std::env::temp_dir()) + || path.starts_with("/tmp") + || path.starts_with("/private/tmp") + || path.starts_with("/var/folders") + || path.starts_with("/private/var/folders") +} + +fn is_codex_internal_directory(path: &Path, codex_home: Option<&Path>) -> bool { + codex_home.is_some_and(|codex_home| { + path.starts_with(codex_home.join("worktrees")) + || path.starts_with(codex_home.join("memories")) + }) +} + +fn read_session_sample(path: &Path) -> Option { + let file = File::open(path).ok()?; + let reader = BufReader::new(file); + + let mut source = None; + let mut turn_context = None; + + for line in reader.lines().take(HEAD_LINES_TO_SCAN) { + let line = line.ok()?; + let rollout_line = serde_json::from_str::(&line).ok()?; + match rollout_line.item { + RolloutItem::SessionMeta(meta_line) => { + source = Some(meta_line.meta.source); + } + RolloutItem::TurnContext(context) => { + turn_context = Some(context); + if source.is_some() { + break; + } + } + _ => {} + } + } + + let source = source?; + if !is_interactive_source(&source) { + return None; + } + let turn_context = turn_context?; + + Some(SessionRecommendationSample { + cwd: turn_context.cwd, + sandbox_policy: turn_context.sandbox_policy, + }) +} + +fn is_interactive_source(source: &SessionSource) -> bool { + INTERACTIVE_SESSION_SOURCES.contains(source) +} + +fn newest_rollout_paths(root: &Path, limit: usize) -> Vec { + let mut paths = Vec::new(); + collect_rollout_paths(root, limit, &mut paths); + paths +} + +fn collect_rollout_paths(dir: &Path, limit: usize, paths: &mut Vec) { + if paths.len() >= limit { + return; + } + + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + let mut entries = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect::>(); + entries.sort(); + entries.reverse(); + + for path in entries { + if paths.len() >= limit { + return; + } + if path.is_dir() { + collect_rollout_paths(&path, limit, paths); + continue; + } + if is_rollout_path(&path) { + paths.push(path); + } + } +} + +fn is_rollout_path(path: &Path) -> bool { + path.extension().is_some_and(|ext| ext == "jsonl") + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("rollout-")) +} + +#[cfg(test)] +#[path = "config_wizard_recommendation_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/setup/config_wizard_recommendation_tests.rs b/codex-rs/tui/src/setup/config_wizard_recommendation_tests.rs new file mode 100644 index 0000000000..be91222e98 --- /dev/null +++ b/codex-rs/tui/src/setup/config_wizard_recommendation_tests.rs @@ -0,0 +1,190 @@ +use super::*; +use codex_protocol::ThreadId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::TurnContextItem; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn detects_recent_workspace_write_defaults_and_common_directories() { + let tempdir = TempDir::new().expect("tempdir"); + let sessions_root = tempdir.path().join("sessions"); + let current_cwd = PathBuf::from("/Users/viyatb/.codex/worktrees/d003/codex"); + + write_rollout( + &sessions_root, + "2026/03/13/rollout-2026-03-13T18-03-05-00000000-0000-0000-0000-000000000001.jsonl", + SessionSource::VSCode, + PathBuf::from("/Users/viyatb/code/openai"), + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/infra") + .expect("absolute path"), + ], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + ); + write_rollout( + &sessions_root, + "2026/03/13/rollout-2026-03-13T17-03-05-00000000-0000-0000-0000-000000000002.jsonl", + SessionSource::Cli, + PathBuf::from("/Users/viyatb/code/openai"), + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/infra") + .expect("absolute path"), + AbsolutePathBuf::from_absolute_path("/tmp/should-not-appear") + .expect("absolute path"), + ], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + ); + write_rollout( + &sessions_root, + "2026/03/13/rollout-2026-03-13T16-03-05-00000000-0000-0000-0000-000000000003.jsonl", + SessionSource::Exec, + PathBuf::from("/Users/viyatb/code/ignored"), + SandboxPolicy::DangerFullAccess, + ); + + let recommendation = + detect_config_wizard_recommendation_in_root(sessions_root, Some(¤t_cwd)) + .expect("recommendation"); + + assert_eq!(recommendation.access_mode, SandboxMode::WorkspaceWrite); + assert!(recommendation.network_access); + assert_eq!(recommendation.sampled_sessions, 2); + assert_eq!(recommendation.workspace_write_sessions, 2); + assert_eq!(recommendation.workspace_write_network_sessions, 2); + assert_eq!( + recommendation.directories, + vec![ + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/infra").expect("absolute path"), + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/openai") + .expect("absolute path"), + ] + ); +} + +#[test] +fn summary_mentions_network_and_directories() { + let recommendation = ConfigWizardRecommendation { + access_mode: SandboxMode::WorkspaceWrite, + network_access: true, + directories: vec![ + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/openai") + .expect("absolute path"), + ], + sampled_sessions: 12, + workspace_write_sessions: 8, + workspace_write_network_sessions: 6, + }; + + assert_eq!( + recommendation.summary(), + "Recommended from 12 recent interactive sessions: workspace-write access; network enabled in 6/8 workspace-write sessions; common directories: /Users/viyatb/code/openai." + ); +} + +#[test] +fn filters_codex_worktree_directories_from_recommendations() { + let tempdir = TempDir::new().expect("tempdir"); + let sessions_root = tempdir.path().join("sessions"); + let current_cwd = tempdir.path().join("worktrees/d003/codex"); + + write_rollout( + &sessions_root, + "2026/03/13/rollout-2026-03-13T18-03-05-00000000-0000-0000-0000-000000000004.jsonl", + SessionSource::Cli, + tempdir.path().join("worktrees/bb2d/codex"), + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::try_from(tempdir.path().join("worktrees/c84e/codex")) + .expect("absolute path"), + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/codex") + .expect("absolute path"), + ], + read_only_access: Default::default(), + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + ); + + let recommendation = + detect_config_wizard_recommendation_in_root(sessions_root, Some(¤t_cwd)) + .expect("recommendation"); + + assert_eq!( + recommendation.directories, + vec![ + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/codex").expect("absolute path"), + ] + ); +} + +fn write_rollout( + sessions_root: &Path, + relative_path: &str, + source: SessionSource, + cwd: PathBuf, + sandbox_policy: SandboxPolicy, +) { + let path = sessions_root.join(relative_path); + fs::create_dir_all(path.parent().expect("parent")).expect("mkdirs"); + + let session_meta = RolloutLine { + timestamp: "2026-03-13T18:03:05Z".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + id: ThreadId::default(), + timestamp: "2026-03-13T18:03:05Z".to_string(), + cwd: cwd.clone(), + originator: "Codex Desktop".to_string(), + cli_version: "0.1.0".to_string(), + source, + ..Default::default() + }, + git: None, + }), + }; + let turn_context = RolloutLine { + timestamp: "2026-03-13T18:03:06Z".to_string(), + item: RolloutItem::TurnContext(TurnContextItem { + turn_id: None, + trace_id: None, + cwd, + current_date: None, + timezone: None, + approval_policy: AskForApproval::OnRequest, + sandbox_policy, + network: None, + model: "gpt-5.4".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: Default::default(), + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }), + }; + + let payload = format!( + "{}\n{}\n", + serde_json::to_string(&session_meta).expect("serialize session meta"), + serde_json::to_string(&turn_context).expect("serialize turn context") + ); + fs::write(path, payload).expect("write rollout"); +} diff --git a/codex-rs/tui/src/setup/config_wizard_state.rs b/codex-rs/tui/src/setup/config_wizard_state.rs new file mode 100644 index 0000000000..43062f875e --- /dev/null +++ b/codex-rs/tui/src/setup/config_wizard_state.rs @@ -0,0 +1,472 @@ +use crate::config_wizard_recommendation::ConfigWizardRecommendation; +use crate::config_wizard_recommendation::detect_config_wizard_recommendation_for_codex_home; +use anyhow::Result; +use codex_core::config::Config; +use codex_core::config::edit::ConfigEdit as CoreConfigEdit; +use codex_core::config::edit::set_path_toml_edit; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::Path; +use std::path::PathBuf; +use toml::Value as TomlValue; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConfigWizardAccessMode { + ReadOnly, + WorkspaceWrite, + FullAccess, +} + +impl ConfigWizardAccessMode { + fn from_sandbox_policy(policy: &SandboxPolicy) -> Self { + match policy { + SandboxPolicy::DangerFullAccess => Self::FullAccess, + SandboxPolicy::ReadOnly { .. } => Self::ReadOnly, + SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::ExternalSandbox { .. } => { + Self::WorkspaceWrite + } + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::ReadOnly => "Read only", + Self::WorkspaceWrite => "Workspace write", + Self::FullAccess => "Full access", + } + } + + pub(crate) fn description(self) -> &'static str { + match self { + Self::ReadOnly => { + "Writes `approval_policy = \"on-request\"` and `sandbox_mode = \"read-only\"`." + } + Self::WorkspaceWrite => { + "Writes `approval_policy = \"on-request\"`, `sandbox_mode = \"workspace-write\"`, and lets you configure `[sandbox_workspace_write]`." + } + Self::FullAccess => { + "Writes `approval_policy = \"never\"` and `sandbox_mode = \"danger-full-access\"`." + } + } + } + + fn approval_policy(self) -> AskForApproval { + match self { + Self::ReadOnly | Self::WorkspaceWrite => AskForApproval::OnRequest, + Self::FullAccess => AskForApproval::Never, + } + } + + fn sandbox_mode(self) -> SandboxMode { + match self { + Self::ReadOnly => SandboxMode::ReadOnly, + Self::WorkspaceWrite => SandboxMode::WorkspaceWrite, + Self::FullAccess => SandboxMode::DangerFullAccess, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConfigWizardTextStep { + WritableRoots, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConfigWizardWorkspaceWriteOption { + NetworkAccess, + ExcludeTmpdirEnvVar, + ExcludeSlashTmp, +} + +impl ConfigWizardWorkspaceWriteOption { + pub(crate) const ALL: [Self; 3] = [ + Self::NetworkAccess, + Self::ExcludeTmpdirEnvVar, + Self::ExcludeSlashTmp, + ]; + + pub(crate) fn key(self) -> &'static str { + match self { + Self::NetworkAccess => "network-access", + Self::ExcludeTmpdirEnvVar => "exclude-tmpdir-env-var", + Self::ExcludeSlashTmp => "exclude-slash-tmp", + } + } + + pub(crate) fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|option| option.key() == key) + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::NetworkAccess => "Allow network access", + Self::ExcludeTmpdirEnvVar => "Exclude $TMPDIR", + Self::ExcludeSlashTmp => "Exclude /tmp", + } + } + + pub(crate) fn description(self) -> &'static str { + match self { + Self::NetworkAccess => "Sets `[sandbox_workspace_write].network_access = true`.", + Self::ExcludeTmpdirEnvVar => { + "Sets `[sandbox_workspace_write].exclude_tmpdir_env_var = true`." + } + Self::ExcludeSlashTmp => "Sets `[sandbox_workspace_write].exclude_slash_tmp = true`.", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ConfigWizardState { + pub(crate) access_mode: ConfigWizardAccessMode, + pub(crate) writable_roots: Vec, + pub(crate) network_access: bool, + pub(crate) exclude_tmpdir_env_var: bool, + pub(crate) exclude_slash_tmp: bool, + recommendation: Option, +} + +#[derive(Clone, Debug)] +pub(crate) struct ConfigWizardApplyRequest { + pub(crate) edits: Vec, + pub(crate) summary: String, +} + +impl ConfigWizardState { + pub(crate) fn detect_with_config(config: &Config) -> Self { + let recommendation = detect_config_wizard_recommendation_for_codex_home( + config.codex_home.as_path(), + Some(config.cwd.as_path()), + ); + let mut state = Self::new(recommendation.clone()); + state.apply_current_policy( + config.permissions.sandbox_policy.get(), + config.codex_home.as_path(), + ); + if let Some(recommendation) = recommendation.as_ref() { + if !matches!( + config.permissions.sandbox_policy.get(), + SandboxPolicy::WorkspaceWrite { .. } + ) { + state.network_access = recommendation.network_access; + } + state.extend_writable_roots(&recommendation.directories); + } + state + } + + fn new(recommendation: Option) -> Self { + Self { + access_mode: ConfigWizardAccessMode::WorkspaceWrite, + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + recommendation, + } + } + + fn apply_current_policy(&mut self, policy: &SandboxPolicy, codex_home: &Path) { + self.access_mode = ConfigWizardAccessMode::from_sandbox_policy(policy); + match policy { + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + .. + } => { + self.writable_roots = visible_writable_roots(writable_roots, codex_home); + self.network_access = *network_access; + self.exclude_tmpdir_env_var = *exclude_tmpdir_env_var; + self.exclude_slash_tmp = *exclude_slash_tmp; + } + SandboxPolicy::ExternalSandbox { network_access } => { + self.network_access = network_access.is_enabled(); + } + SandboxPolicy::ReadOnly { .. } | SandboxPolicy::DangerFullAccess => {} + } + } + + fn extend_writable_roots(&mut self, roots: &[AbsolutePathBuf]) { + for root in roots { + if !self.writable_roots.contains(root) { + self.writable_roots.push(root.clone()); + } + } + } + + #[cfg(test)] + pub(crate) fn test_state() -> Self { + Self::new(None) + } + + pub(crate) fn uses_workspace_write(&self) -> bool { + self.access_mode == ConfigWizardAccessMode::WorkspaceWrite + } + + pub(crate) fn apply_text_step( + &mut self, + step: ConfigWizardTextStep, + value: String, + ) -> Result<()> { + match step { + ConfigWizardTextStep::WritableRoots => { + self.writable_roots = parse_writable_roots(&value)?; + } + } + Ok(()) + } + + pub(crate) fn set_workspace_write_options( + &mut self, + selected: &[ConfigWizardWorkspaceWriteOption], + ) { + self.network_access = selected.contains(&ConfigWizardWorkspaceWriteOption::NetworkAccess); + self.exclude_tmpdir_env_var = + selected.contains(&ConfigWizardWorkspaceWriteOption::ExcludeTmpdirEnvVar); + self.exclude_slash_tmp = + selected.contains(&ConfigWizardWorkspaceWriteOption::ExcludeSlashTmp); + } + + pub(crate) fn selected_workspace_write_options(&self) -> Vec { + [ + ( + self.network_access, + ConfigWizardWorkspaceWriteOption::NetworkAccess, + ), + ( + self.exclude_tmpdir_env_var, + ConfigWizardWorkspaceWriteOption::ExcludeTmpdirEnvVar, + ), + ( + self.exclude_slash_tmp, + ConfigWizardWorkspaceWriteOption::ExcludeSlashTmp, + ), + ] + .into_iter() + .filter(|(enabled, _)| *enabled) + .map(|(_, option)| option) + .collect() + } + + pub(crate) fn build_apply_request(&self) -> Result { + let mut edits = vec![ + CoreConfigEdit::ClearPath { + segments: vec!["default_permissions".to_string()], + }, + CoreConfigEdit::ClearPath { + segments: vec!["permissions".to_string()], + }, + CoreConfigEdit::ClearPath { + segments: vec!["sandbox_workspace_write".to_string()], + }, + ]; + + for (key, value) in self.config_entries() { + edits.push(set_path_toml_edit(vec![key.to_string()], value)?); + } + + Ok(ConfigWizardApplyRequest { + edits, + summary: "Saved `/setup-sandbox` settings to config.toml.".to_string(), + }) + } + + pub(crate) fn preview_toml(&self) -> String { + let mut root = toml::map::Map::new(); + for (key, value) in self.config_entries() { + root.insert(key.to_string(), value); + } + toml::to_string_pretty(&TomlValue::Table(root)) + .unwrap_or_else(|_| "# Failed to render preview".to_string()) + } + + pub(crate) fn access_mode_subtitle(&self) -> String { + let mut subtitle = + "This flow writes sandbox settings to config.toml. Current session settings stay selected by default." + .to_string(); + if let Some(summary) = self.recommendation_summary() { + subtitle.push_str("\n\n"); + subtitle.push_str(&summary); + } + subtitle + } + + pub(crate) fn workspace_write_subtitle(&self) -> String { + let mut subtitle = + "These map directly to `[sandbox_workspace_write]`. Current session settings are preselected, and the next step lets you review directories you work in." + .to_string(); + if let Some(recommendation) = self.recommendation.as_ref() + && recommendation.workspace_write_sessions > 0 + { + subtitle.push_str(&format!( + "\n\nNetwork was {} in {}/{} recent workspace-write sessions.", + if recommendation.network_access { + "enabled" + } else { + "disabled" + }, + recommendation.workspace_write_network_sessions, + recommendation.workspace_write_sessions + )); + } + subtitle + } + + pub(crate) fn prompt_title(step: ConfigWizardTextStep) -> &'static str { + match step { + ConfigWizardTextStep::WritableRoots => "Directories You Work In", + } + } + + pub(crate) fn prompt_placeholder(step: ConfigWizardTextStep) -> &'static str { + match step { + ConfigWizardTextStep::WritableRoots => { + "One absolute path per line, for example:\n/Users/me/code/openai\n/Users/me/code/infra" + } + } + } + + pub(crate) fn prompt_initial_value(&self, step: ConfigWizardTextStep) -> Option { + match step { + ConfigWizardTextStep::WritableRoots => { + if self.writable_roots.is_empty() { + None + } else { + Some( + self.writable_roots + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join("\n"), + ) + } + } + } + } + + pub(crate) fn prompt_context(&self, step: ConfigWizardTextStep) -> Option { + match step { + ConfigWizardTextStep::WritableRoots => { + let mut context = + "Add other absolute directories you regularly want Codex to edit. The current workspace is already writable, and any directories from your current sandbox settings stay prefilled. Press Enter to continue, or Esc to go back." + .to_string(); + if let Some(summary) = self.recommendation_summary() + && !self.writable_roots.is_empty() + { + context.push_str("\n\n"); + context.push_str(&summary); + context.push_str( + " Recent-session suggestions are included below so you can keep, remove, or add directories.", + ); + } + Some(context) + } + } + } + + pub(crate) fn recommendation_summary(&self) -> Option { + self.recommendation + .as_ref() + .map(ConfigWizardRecommendation::summary) + } + + fn sandbox_workspace_write_toml(&self) -> Option> { + if !self.uses_workspace_write() { + return None; + } + + let mut table = toml::map::Map::new(); + table.insert( + "writable_roots".to_string(), + TomlValue::Array( + self.writable_roots + .iter() + .map(|path| TomlValue::String(path.display().to_string())) + .collect(), + ), + ); + table.insert( + "network_access".to_string(), + TomlValue::Boolean(self.network_access), + ); + table.insert( + "exclude_tmpdir_env_var".to_string(), + TomlValue::Boolean(self.exclude_tmpdir_env_var), + ); + table.insert( + "exclude_slash_tmp".to_string(), + TomlValue::Boolean(self.exclude_slash_tmp), + ); + Some(table) + } + + fn config_entries(&self) -> Vec<(&'static str, TomlValue)> { + let mut entries = vec![ + ( + "approval_policy", + TomlValue::String(self.access_mode.approval_policy().to_string()), + ), + ( + "sandbox_mode", + TomlValue::String(self.access_mode.sandbox_mode().to_string()), + ), + ]; + + if let Some(sandbox_workspace_write) = self.sandbox_workspace_write_toml() { + entries.push(( + "sandbox_workspace_write", + TomlValue::Table(sandbox_workspace_write), + )); + } + + entries + } +} + +fn parse_writable_roots(value: &str) -> Result> { + let mut roots = Vec::new(); + for line in value.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let path = PathBuf::from(trimmed); + if !path.is_absolute() { + return Err(anyhow::anyhow!( + "directory `{trimmed}` must be an absolute path" + )); + } + + let absolute = AbsolutePathBuf::try_from(path).map_err(|err| { + anyhow::anyhow!("directory `{trimmed}` is not a valid absolute path: {err}") + })?; + if !roots.contains(&absolute) { + roots.push(absolute); + } + } + Ok(roots) +} + +fn visible_writable_roots( + writable_roots: &[AbsolutePathBuf], + codex_home: &Path, +) -> Vec { + writable_roots + .iter() + .filter(|path| !is_hidden_codex_directory(path.as_path(), codex_home)) + .cloned() + .collect() +} + +fn is_hidden_codex_directory(path: &Path, codex_home: &Path) -> bool { + path.starts_with(codex_home.join("worktrees")) || path.starts_with(codex_home.join("memories")) +} + +#[cfg(test)] +#[path = "config_wizard_state_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/setup/config_wizard_state_tests.rs b/codex-rs/tui/src/setup/config_wizard_state_tests.rs new file mode 100644 index 0000000000..e02e4a1e9d --- /dev/null +++ b/codex-rs/tui/src/setup/config_wizard_state_tests.rs @@ -0,0 +1,149 @@ +use super::*; +use codex_core::config::ConfigBuilder; +use insta::assert_snapshot; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn preview_toml_writes_legacy_workspace_write_config() { + let state = ConfigWizardState { + access_mode: ConfigWizardAccessMode::WorkspaceWrite, + writable_roots: vec![ + AbsolutePathBuf::from_absolute_path("/tmp/pip-cache").expect("absolute path"), + AbsolutePathBuf::from_absolute_path("/tmp/ollama").expect("absolute path"), + ], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + recommendation: None, + }; + + assert_snapshot!(state.preview_toml()); +} + +#[test] +fn build_apply_request_clears_permissions_profiles() { + let state = ConfigWizardState { + access_mode: ConfigWizardAccessMode::ReadOnly, + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + recommendation: None, + }; + + let request = state + .build_apply_request() + .expect("expected apply request to build"); + assert_eq!(request.edits.len(), 5); + assert!(matches!( + &request.edits[0], + CoreConfigEdit::ClearPath { segments } + if segments == &vec!["default_permissions".to_string()] + )); + assert!(matches!( + &request.edits[1], + CoreConfigEdit::ClearPath { segments } + if segments == &vec!["permissions".to_string()] + )); + assert!(matches!( + &request.edits[2], + CoreConfigEdit::ClearPath { segments } + if segments == &vec!["sandbox_workspace_write".to_string()] + )); + match &request.edits[3] { + CoreConfigEdit::SetPath { segments, value } => { + assert_eq!(segments, &vec!["approval_policy".to_string()]); + assert_eq!(value.to_string().trim(), "\"on-request\""); + } + edit => panic!("unexpected edit: {edit:?}"), + } + match &request.edits[4] { + CoreConfigEdit::SetPath { segments, value } => { + assert_eq!(segments, &vec!["sandbox_mode".to_string()]); + assert_eq!(value.to_string().trim(), "\"read-only\""); + } + edit => panic!("unexpected edit: {edit:?}"), + } +} + +#[test] +fn parse_writable_roots_rejects_relative_paths() { + let err = parse_writable_roots("./cache").expect_err("relative paths should be rejected"); + assert_eq!( + err.to_string(), + "directory `./cache` must be an absolute path" + ); +} + +#[test] +fn access_mode_subtitle_includes_recommendation_summary() { + let state = ConfigWizardState { + access_mode: ConfigWizardAccessMode::WorkspaceWrite, + writable_roots: vec![ + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/openai") + .expect("absolute path"), + ], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + recommendation: Some(ConfigWizardRecommendation { + access_mode: SandboxMode::WorkspaceWrite, + network_access: true, + directories: vec![ + AbsolutePathBuf::from_absolute_path("/Users/viyatb/code/openai") + .expect("absolute path"), + ], + sampled_sessions: 12, + workspace_write_sessions: 8, + workspace_write_network_sessions: 6, + }), + }; + + assert_snapshot!(state.access_mode_subtitle()); +} + +#[tokio::test] +async fn detect_with_config_uses_current_workspace_write_settings() { + let tempdir = TempDir::new().expect("tempdir"); + let codex_home = tempdir.path().join("codex-home"); + let workspace = tempdir.path().join("workspace"); + let extra_dir = tempdir.path().join("shared"); + std::fs::create_dir_all(&codex_home).expect("codex home"); + std::fs::create_dir_all(&workspace).expect("workspace"); + std::fs::create_dir_all(&extra_dir).expect("extra dir"); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = ["{}"] +network_access = true +exclude_tmpdir_env_var = true +exclude_slash_tmp = true +"#, + extra_dir.display() + ), + ) + .expect("config"); + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(workspace)) + .build() + .await + .expect("config"); + + let state = ConfigWizardState::detect_with_config(&config); + + assert_eq!(state.access_mode, ConfigWizardAccessMode::WorkspaceWrite); + assert_eq!( + state.writable_roots, + vec![AbsolutePathBuf::try_from(extra_dir).expect("absolute path")] + ); + assert!(state.network_access); + assert!(state.exclude_tmpdir_env_var); + assert!(state.exclude_slash_tmp); +} diff --git a/codex-rs/tui/src/setup/snapshots/codex_tui__config_wizard_state__tests__access_mode_subtitle_includes_recommendation_summary.snap b/codex-rs/tui/src/setup/snapshots/codex_tui__config_wizard_state__tests__access_mode_subtitle_includes_recommendation_summary.snap new file mode 100644 index 0000000000..e587d506c3 --- /dev/null +++ b/codex-rs/tui/src/setup/snapshots/codex_tui__config_wizard_state__tests__access_mode_subtitle_includes_recommendation_summary.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/setup/config_wizard_state_tests.rs +assertion_line: 97 +expression: state.access_mode_subtitle() +--- +This flow writes sandbox settings to config.toml. Current session settings stay selected by default. + +Recommended from 12 recent interactive sessions: workspace-write access; network enabled in 6/8 workspace-write sessions; common directories: /Users/viyatb/code/openai. diff --git a/codex-rs/tui/src/setup/snapshots/codex_tui__config_wizard_state__tests__preview_toml_writes_legacy_workspace_write_config.snap b/codex-rs/tui/src/setup/snapshots/codex_tui__config_wizard_state__tests__preview_toml_writes_legacy_workspace_write_config.snap new file mode 100644 index 0000000000..d9ccf653bd --- /dev/null +++ b/codex-rs/tui/src/setup/snapshots/codex_tui__config_wizard_state__tests__preview_toml_writes_legacy_workspace_write_config.snap @@ -0,0 +1,16 @@ +--- +source: tui/src/setup/config_wizard_state_tests.rs +assertion_line: 20 +expression: state.preview_toml() +--- +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +exclude_slash_tmp = false +exclude_tmpdir_env_var = true +network_access = true +writable_roots = [ + "/tmp/pip-cache", + "/tmp/ollama", +] diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index b4669645d1..0cb3def72c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -16,6 +16,8 @@ pub enum SlashCommand { Fast, Approvals, Permissions, + #[strum(serialize = "setup-sandbox")] + SetupSandbox, #[strum(serialize = "setup-default-sandbox")] ElevateSandbox, #[strum(serialize = "sandbox-add-read-dir")] @@ -99,6 +101,7 @@ impl SlashCommand { SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex is allowed to do", SlashCommand::Permissions => "choose what Codex is allowed to do", + SlashCommand::SetupSandbox => "set up guided legacy sandbox config", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::SandboxReadRoot => { "let sandbox read a directory: /sandbox-add-read-dir " @@ -144,6 +147,7 @@ impl SlashCommand { | SlashCommand::Personality | SlashCommand::Approvals | SlashCommand::Permissions + | SlashCommand::SetupSandbox | SlashCommand::ElevateSandbox | SlashCommand::SandboxReadRoot | SlashCommand::Experimental