feat(tui): add setup sandbox wizard

This commit is contained in:
viyatb-oai
2026-03-14 00:09:56 -07:00
parent dcc4d7b634
commit 01d52f1430
20 changed files with 1962 additions and 6 deletions
+114
View File
@@ -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<String>, value: TomlValue) -> Result<ConfigEdit> {
Ok(ConfigEdit::SetPath {
segments,
value: toml_value_to_item(value)?,
})
}
pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) -> Vec<ConfigEdit> {
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<TomlItem> {
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<toml_edit::Value> {
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<String>,
@@ -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::<TomlValue>(&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");
+63
View File
@@ -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 => {
+36
View File
@@ -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<ServiceTier>,
},
/// 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<ConfigWizardWorkspaceWriteOption>,
},
/// 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,
@@ -23,6 +23,8 @@ use super::textarea::TextAreaState;
/// Callback invoked when the user submits a custom prompt.
pub(crate) type PromptSubmitted = Box<dyn Fn(String) + Send + Sync>;
/// Callback invoked when the user cancels a custom prompt.
pub(crate) type PromptCancelled = Box<dyn Fn() + Send + Sync>;
/// 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<String>,
on_submit: PromptSubmitted,
on_cancel: Option<PromptCancelled>,
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
}
@@ -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::<AppEvent>();
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::<AppEvent>();
+4 -1
View File
@@ -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;
@@ -157,6 +157,9 @@ pub(crate) struct MultiSelectPicker {
/// Callback invoked when the user cancels the picker.
on_cancel: Option<CancelCallback>,
/// 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<ChangeCallBack>,
on_confirm: Option<ConfirmCallback>,
on_cancel: Option<CancelCallback>,
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();
@@ -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();
@@ -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
+83
View File
@@ -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<ConfigWizardState>,
/// 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<crate::config_wizard::ConfigWizardWorkspaceWriteOption>,
) {
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<ExperimentalFeatureItem> = FEATURES
.iter()
+1
View File
@@ -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)
+6
View File
@@ -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;
+339
View File
@@ -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<ListSelectionView> {
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<AppEvent> {
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::<Vec<_>>();
if selected.is_empty() {
format!("{prefix}: none")
} else {
format!("{prefix}: {}", selected.join(", "))
}
}
fn preview_renderable(state: &ConfigWizardState) -> Box<dyn crate::render::renderable::Renderable> {
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<AppEvent>) {
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:?}"),
}
}
}
@@ -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<AbsolutePathBuf>,
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::<Vec<_>>()
.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<ConfigWizardRecommendation> {
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<ConfigWizardRecommendation> {
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<AbsolutePathBuf> {
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<AbsolutePathBuf> {
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<AbsolutePathBuf>,
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<SessionRecommendationSample> {
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::<RolloutLine>(&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<PathBuf> {
let mut paths = Vec::new();
collect_rollout_paths(root, limit, &mut paths);
paths
}
fn collect_rollout_paths(dir: &Path, limit: usize, paths: &mut Vec<PathBuf>) {
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::<Vec<_>>();
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;
@@ -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(&current_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(&current_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");
}
@@ -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> {
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<AbsolutePathBuf>,
pub(crate) network_access: bool,
pub(crate) exclude_tmpdir_env_var: bool,
pub(crate) exclude_slash_tmp: bool,
recommendation: Option<ConfigWizardRecommendation>,
}
#[derive(Clone, Debug)]
pub(crate) struct ConfigWizardApplyRequest {
pub(crate) edits: Vec<CoreConfigEdit>,
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<ConfigWizardRecommendation>) -> 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<ConfigWizardWorkspaceWriteOption> {
[
(
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<ConfigWizardApplyRequest> {
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<String> {
match step {
ConfigWizardTextStep::WritableRoots => {
if self.writable_roots.is_empty() {
None
} else {
Some(
self.writable_roots
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join("\n"),
)
}
}
}
}
pub(crate) fn prompt_context(&self, step: ConfigWizardTextStep) -> Option<String> {
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<String> {
self.recommendation
.as_ref()
.map(ConfigWizardRecommendation::summary)
}
fn sandbox_workspace_write_toml(&self) -> Option<toml::map::Map<String, TomlValue>> {
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<Vec<AbsolutePathBuf>> {
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<AbsolutePathBuf> {
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;
@@ -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);
}
@@ -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.
@@ -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",
]
+4
View File
@@ -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 <absolute_path>"
@@ -144,6 +147,7 @@ impl SlashCommand {
| SlashCommand::Personality
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::SetupSandbox
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental