[config] Onboarding flow with persistence (#1929)

## Summary
In collaboration with @gpeal: upgrade the onboarding flow, and persist
user settings.

---------

Co-authored-by: Gabriel Peal <gabriel@openai.com>
This commit is contained in:
Dylan
2025-08-07 09:27:38 -07:00
committed by GitHub
parent 7e9ecfbc6a
commit bc28b87c7b
13 changed files with 435 additions and 190 deletions

24
codex-rs/Cargo.lock generated
View File

@@ -708,6 +708,7 @@ dependencies = [
"tokio-test",
"tokio-util",
"toml 0.9.4",
"toml_edit 0.23.3",
"tracing",
"tree-sitter",
"tree-sitter-bash",
@@ -3273,7 +3274,7 @@ version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit",
"toml_edit 0.22.27",
]
[[package]]
@@ -4800,7 +4801,7 @@ dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit",
"toml_edit 0.22.27",
]
[[package]]
@@ -4850,10 +4851,23 @@ dependencies = [
]
[[package]]
name = "toml_parser"
version = "1.0.1"
name = "toml_edit"
version = "0.23.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
checksum = "17d3b47e6b7a040216ae5302712c94d1cf88c95b47efa80e2c59ce96c878267e"
dependencies = [
"indexmap 2.10.0",
"toml_datetime 0.7.0",
"toml_parser",
"toml_writer",
"winnow",
]
[[package]]
name = "toml_parser"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10"
dependencies = [
"winnow",
]

View File

@@ -36,6 +36,7 @@ sha1 = "0.10.6"
shlex = "1.3.0"
similar = "2.7.0"
strum_macros = "0.27.2"
tempfile = "3"
thiserror = "2.0.12"
time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
tokio = { version = "1", features = [
@@ -47,6 +48,7 @@ tokio = { version = "1", features = [
] }
tokio-util = "0.7.14"
toml = "0.9.4"
toml_edit = "0.23.3"
tracing = { version = "0.1.41", features = ["log"] }
tree-sitter = "0.25.8"
tree-sitter-bash = "0.25.0"

View File

@@ -22,13 +22,17 @@ use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use tempfile::NamedTempFile;
use toml::Value as TomlValue;
use toml_edit::DocumentMut;
/// Maximum number of bytes of the documentation that will be embedded. Larger
/// files are *silently truncated* to this size so we do not take up too much of
/// the context window.
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
const CONFIG_TOML_FILE: &str = "config.toml";
/// Application configuration loaded from disk and merged with overrides.
#[derive(Debug, Clone, PartialEq)]
pub struct Config {
@@ -191,10 +195,28 @@ impl Config {
}
}
pub fn load_config_as_toml_with_cli_overrides(
codex_home: &Path,
cli_overrides: Vec<(String, TomlValue)>,
) -> std::io::Result<ConfigToml> {
let mut root_value = load_config_as_toml(codex_home)?;
for (path, value) in cli_overrides.into_iter() {
apply_toml_override(&mut root_value, &path, value);
}
let cfg: ConfigToml = root_value.try_into().map_err(|e| {
tracing::error!("Failed to deserialize overridden config: {e}");
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
Ok(cfg)
}
/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns
/// an empty TOML table when the file does not exist.
fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
let config_path = codex_home.join("config.toml");
pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
match std::fs::read_to_string(&config_path) {
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
Ok(val) => Ok(val),
@@ -214,6 +236,35 @@ fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
}
}
/// Patch `CODEX_HOME/config.toml` project state.
/// Use with caution.
pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
// Parse existing config if present; otherwise start a new document.
let mut doc = match std::fs::read_to_string(config_path.clone()) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Mark the project as trusted. toml_edit is very good at handling
// missing properties
let project_key = project_path.to_string_lossy().to_string();
doc["projects"][project_key.as_str()]["trust_level"] = toml_edit::value("trusted");
// ensure codex_home exists
std::fs::create_dir_all(codex_home)?;
// create a tmp_file
let tmp_file = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp_file.path(), doc.to_string())?;
// atomically move the tmp file into config.toml
tmp_file.persist(config_path)?;
Ok(())
}
/// Apply a single dotted-path override onto a TOML value.
fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
use toml::value::Table;
@@ -350,6 +401,13 @@ pub struct ConfigToml {
/// The value for the `originator` header included with Responses API requests.
pub internal_originator: Option<String>,
pub projects: Option<HashMap<String, ProjectConfig>>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct ProjectConfig {
pub trust_level: Option<String>,
}
impl ConfigToml {
@@ -377,6 +435,36 @@ impl ConfigToml {
SandboxMode::DangerFullAccess => SandboxPolicy::DangerFullAccess,
}
}
pub fn is_cwd_trusted(&self, resolved_cwd: &Path) -> bool {
let projects = self.projects.clone().unwrap_or_default();
projects
.get(&resolved_cwd.to_string_lossy().to_string())
.map(|p| p.trust_level.clone().unwrap_or("".to_string()) == "trusted")
.unwrap_or(false)
}
pub fn get_config_profile(
&self,
override_profile: Option<String>,
) -> Result<ConfigProfile, std::io::Error> {
let profile = override_profile.or_else(|| self.profile.clone());
match profile {
Some(key) => {
if let Some(profile) = self.profiles.get(key.as_str()) {
return Ok(profile.clone());
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("config profile `{key}` not found"),
))
}
None => Ok(ConfigProfile::default()),
}
}
}
/// Optional overrides for user configuration (e.g., from CLI flags).

View File

@@ -139,7 +139,6 @@ pub enum AskForApproval {
/// Under this policy, only "known safe" commands—as determined by
/// `is_safe_command()`—that **only read files** are autoapproved.
/// Everything else will ask the user to approve.
#[default]
#[serde(rename = "untrusted")]
#[strum(serialize = "untrusted")]
UnlessTrusted,
@@ -151,6 +150,7 @@ pub enum AskForApproval {
OnFailure,
/// The model decides when to ask the user for approval.
#[default]
OnRequest,
/// Never ask the user to approve commands. Failures are immediately returned

View File

@@ -181,7 +181,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
event_processor.print_config_summary(&config, &prompt);
if !skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf()) {
eprintln!("Not inside a Git repo and --skip-git-repo-check was not specified.");
eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified.");
std::process::exit(1);
}

View File

@@ -13,7 +13,6 @@ use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use codex_core::util::is_inside_git_repo;
use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode;
@@ -71,7 +70,7 @@ pub(crate) struct App<'a> {
/// deferred until after the Git warning screen is dismissed.
#[derive(Clone, Debug)]
pub(crate) struct ChatWidgetArgs {
config: Config,
pub(crate) config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
@@ -81,8 +80,8 @@ impl App<'_> {
pub(crate) fn new(
config: Config,
initial_prompt: Option<String>,
skip_git_repo_check: bool,
initial_images: Vec<std::path::PathBuf>,
show_trust_screen: bool,
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
@@ -134,9 +133,7 @@ impl App<'_> {
}
let show_login_screen = should_show_login_screen(&config);
let show_git_warning =
!skip_git_repo_check && !is_inside_git_repo(&config.cwd.to_path_buf());
let app_state = if show_login_screen || show_git_warning {
let app_state = if show_login_screen || show_trust_screen {
let chat_widget_args = ChatWidgetArgs {
config: config.clone(),
initial_prompt,
@@ -149,7 +146,7 @@ impl App<'_> {
codex_home: config.codex_home.clone(),
cwd: config.cwd.clone(),
show_login_screen,
show_git_warning,
show_trust_screen,
chat_widget_args,
}),
}

View File

@@ -54,10 +54,6 @@ pub struct Cli {
#[clap(long = "cd", short = 'C', value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Allow running Codex outside a Git repository.
#[arg(long = "skip-git-repo-check", default_value_t = false)]
pub skip_git_repo_check: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}

View File

@@ -6,8 +6,12 @@ use app::App;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config_types::SandboxMode;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_login::load_auth;
use codex_ollama::DEFAULT_OSS_MODEL;
use log_layer::TuiLogLayer;
@@ -89,33 +93,38 @@ pub async fn run_main(
None
};
let config = {
// canonicalize the cwd
let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p));
let overrides = ConfigOverrides {
model,
approval_policy,
sandbox_mode,
cwd,
model_provider: model_provider_override,
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
include_plan_tool: Some(true),
disable_response_storage: cli.oss.then_some(true),
show_raw_agent_reasoning: cli.oss.then_some(true),
};
// Parse `-c` overrides from the CLI.
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
Ok(v) => v,
#[allow(clippy::print_stderr)]
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
let mut config = {
// Load configuration and support CLI overrides.
let overrides = ConfigOverrides {
model,
approval_policy,
sandbox_mode,
cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)),
model_provider: model_provider_override,
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
include_plan_tool: Some(true),
disable_response_storage: cli.oss.then_some(true),
show_raw_agent_reasoning: cli.oss.then_some(true),
};
// Parse `-c` overrides from the CLI.
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
Ok(v) => v,
#[allow(clippy::print_stderr)]
Err(e) => {
eprintln!("Error parsing -c overrides: {e}");
std::process::exit(1);
}
};
#[allow(clippy::print_stderr)]
match Config::load_with_cli_overrides(cli_kv_overrides, overrides) {
match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides) {
Ok(config) => config,
Err(err) => {
eprintln!("Error loading configuration: {err}");
@@ -124,6 +133,34 @@ pub async fn run_main(
}
};
// we load config.toml here to determine project state.
#[allow(clippy::print_stderr)]
let config_toml = {
let codex_home = match find_codex_home() {
Ok(codex_home) => codex_home,
Err(err) => {
eprintln!("Error finding codex home: {err}");
std::process::exit(1);
}
};
match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides) {
Ok(config_toml) => config_toml,
Err(err) => {
eprintln!("Error loading config.toml: {err}");
std::process::exit(1);
}
}
};
let should_show_trust_screen = determine_repo_trust_state(
&mut config,
&config_toml,
approval_policy,
sandbox_mode,
cli.config_profile.clone(),
)?;
let log_dir = codex_core::config::log_dir(&config)?;
std::fs::create_dir_all(&log_dir)?;
// Open (or create) your log file, appending to it.
@@ -204,12 +241,14 @@ pub async fn run_main(
eprintln!("");
}
run_ratatui_app(cli, config, log_rx).map_err(|err| std::io::Error::other(err.to_string()))
run_ratatui_app(cli, config, should_show_trust_screen, log_rx)
.map_err(|err| std::io::Error::other(err.to_string()))
}
fn run_ratatui_app(
cli: Cli,
config: Config,
should_show_trust_screen: bool,
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
color_eyre::install()?;
@@ -227,7 +266,7 @@ fn run_ratatui_app(
terminal.clear()?;
let Cli { prompt, images, .. } = cli;
let mut app = App::new(config.clone(), prompt, cli.skip_git_repo_check, images);
let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
{
@@ -277,3 +316,39 @@ fn should_show_login_screen(config: &Config) -> bool {
false
}
}
/// Determine if user has configured a sandbox / approval policy,
/// or if the current cwd project is trusted, and updates the config
/// accordingly.
fn determine_repo_trust_state(
config: &mut Config,
config_toml: &ConfigToml,
approval_policy_overide: Option<AskForApproval>,
sandbox_mode_override: Option<SandboxMode>,
config_profile_override: Option<String>,
) -> std::io::Result<bool> {
let config_profile = config_toml.get_config_profile(config_profile_override)?;
if approval_policy_overide.is_some() || sandbox_mode_override.is_some() {
// if the user has overridden either approval policy or sandbox mode,
// skip the trust flow
Ok(false)
} else if config_profile.approval_policy.is_some() {
// if the user has specified settings in a config profile, skip the trust flow
// todo: profile sandbox mode?
Ok(false)
} else if config_toml.approval_policy.is_some() || config_toml.sandbox_mode.is_some() {
// if the user has specified either approval policy or sandbox mode in config.toml
// skip the trust flow
Ok(false)
} else if config_toml.is_cwd_trusted(&config.cwd) {
// if the current cwd project is trusted and no config has been set
// skip the trust flow and set the approval policy and sandbox mode
config.approval_policy = AskForApproval::OnRequest;
config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
Ok(false)
} else {
// if none of the above conditions are met, show the trust screen
Ok(true)
}
}

View File

@@ -8,12 +8,14 @@ use crate::app_event_sender::AppEventSender;
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
use std::sync::Arc;
use std::sync::Mutex;
/// This doesn't render anything explicitly but serves as a signal that we made it to the end and
/// we should continue to the chat.
pub(crate) struct ContinueToChatWidget {
pub event_tx: AppEventSender,
pub chat_widget_args: ChatWidgetArgs,
pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
}
impl StepStateProvider for ContinueToChatWidget {
@@ -24,7 +26,9 @@ impl StepStateProvider for ContinueToChatWidget {
impl WidgetRef for &ContinueToChatWidget {
fn render_ref(&self, _area: Rect, _buf: &mut Buffer) {
self.event_tx
.send(AppEvent::OnboardingComplete(self.chat_widget_args.clone()));
if let Ok(args) = self.chat_widget_args.lock() {
self.event_tx
.send(AppEvent::OnboardingComplete(args.clone()));
}
}
}

View File

@@ -1,126 +0,0 @@
use std::path::PathBuf;
use codex_core::util::is_inside_git_repo;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::colors::LIGHT_BLUE;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
pub(crate) struct GitWarningWidget {
pub event_tx: AppEventSender,
pub cwd: PathBuf,
pub selection: Option<GitWarningSelection>,
pub highlighted: GitWarningSelection,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GitWarningSelection {
Continue,
Exit,
}
impl WidgetRef for &GitWarningWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::raw("> "),
Span::raw("You are running Codex in "),
Span::styled(
self.cwd.to_string_lossy().to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(". This folder is not version controlled."),
]),
Line::from(""),
Line::from(" Do you want to continue?"),
Line::from(""),
];
let create_option =
|idx: usize, option: GitWarningSelection, text: &str| -> Line<'static> {
let is_selected = self.highlighted == option;
if is_selected {
Line::from(vec![
Span::styled(
format!("> {}. ", idx + 1),
Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
),
Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
])
} else {
Line::from(format!(" {}. {}", idx + 1, text))
}
};
lines.push(create_option(0, GitWarningSelection::Continue, "Yes"));
lines.push(create_option(1, GitWarningSelection::Exit, "No"));
lines.push(Line::from(""));
lines.push(Line::from(" Press Enter to continue").add_modifier(Modifier::DIM));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
impl KeyboardHandler for GitWarningWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlighted = GitWarningSelection::Continue;
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted = GitWarningSelection::Exit;
}
KeyCode::Char('1') => self.handle_continue(),
KeyCode::Char('2') => self.handle_quit(),
KeyCode::Enter => match self.highlighted {
GitWarningSelection::Continue => self.handle_continue(),
GitWarningSelection::Exit => self.handle_quit(),
},
_ => {}
}
}
}
impl StepStateProvider for GitWarningWidget {
fn get_step_state(&self) -> StepState {
let is_git_repo = is_inside_git_repo(&self.cwd);
match is_git_repo {
true => StepState::Hidden,
false => match self.selection {
Some(_) => StepState::Complete,
None => StepState::InProgress,
},
}
}
}
impl GitWarningWidget {
fn handle_continue(&mut self) {
self.selection = Some(GitWarningSelection::Continue);
}
fn handle_quit(&mut self) {
self.highlighted = GitWarningSelection::Exit;
self.event_tx.send(AppEvent::ExitRequest);
}
}

View File

@@ -1,5 +1,5 @@
mod auth;
mod continue_to_chat;
mod git_warning;
pub mod onboarding_screen;
mod trust_directory;
mod welcome;

View File

@@ -1,3 +1,4 @@
use codex_core::util::is_inside_git_repo;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -11,16 +12,18 @@ use crate::app_event_sender::AppEventSender;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInState;
use crate::onboarding::continue_to_chat::ContinueToChatWidget;
use crate::onboarding::git_warning::GitWarningSelection;
use crate::onboarding::git_warning::GitWarningWidget;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::onboarding::welcome::WelcomeWidget;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
#[allow(clippy::large_enum_variant)]
enum Step {
Welcome(WelcomeWidget),
Auth(AuthModeWidget),
GitWarning(GitWarningWidget),
TrustDirectory(TrustDirectoryWidget),
ContinueToChat(ContinueToChatWidget),
}
@@ -49,7 +52,7 @@ pub(crate) struct OnboardingScreenArgs {
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub show_login_screen: bool,
pub show_git_warning: bool,
pub show_trust_screen: bool,
}
impl OnboardingScreen {
@@ -60,7 +63,7 @@ impl OnboardingScreen {
codex_home,
cwd,
show_login_screen,
show_git_warning,
show_trust_screen,
} = args;
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
is_logged_in: !show_login_screen,
@@ -71,20 +74,33 @@ impl OnboardingScreen {
highlighted_mode: AuthMode::ChatGPT,
error: None,
sign_in_state: SignInState::PickMode,
codex_home,
codex_home: codex_home.clone(),
}))
}
if show_git_warning {
steps.push(Step::GitWarning(GitWarningWidget {
event_tx: event_tx.clone(),
let is_git_repo = is_inside_git_repo(&cwd);
let highlighted = if is_git_repo {
TrustDirectorySelection::Trust
} else {
// Default to not trusting the directory if it's not a git repo.
TrustDirectorySelection::DontTrust
};
// Share ChatWidgetArgs between steps so changes in the TrustDirectory step
// are reflected when continuing to chat.
let shared_chat_args = Arc::new(Mutex::new(chat_widget_args));
if show_trust_screen {
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd,
codex_home,
is_git_repo,
selection: None,
highlighted: GitWarningSelection::Continue,
highlighted,
error: None,
chat_widget_args: shared_chat_args.clone(),
}))
}
steps.push(Step::ContinueToChat(ContinueToChatWidget {
event_tx: event_tx.clone(),
chat_widget_args,
chat_widget_args: shared_chat_args,
}));
// TODO: add git warning.
Self { event_tx, steps }
@@ -215,7 +231,7 @@ impl KeyboardHandler for Step {
match self {
Step::Welcome(_) | Step::ContinueToChat(_) => (),
Step::Auth(widget) => widget.handle_key_event(key_event),
Step::GitWarning(widget) => widget.handle_key_event(key_event),
Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
}
}
}
@@ -225,7 +241,7 @@ impl StepStateProvider for Step {
match self {
Step::Welcome(w) => w.get_step_state(),
Step::Auth(w) => w.get_step_state(),
Step::GitWarning(w) => w.get_step_state(),
Step::TrustDirectory(w) => w.get_step_state(),
Step::ContinueToChat(w) => w.get_step_state(),
}
}
@@ -240,7 +256,7 @@ impl WidgetRef for Step {
Step::Auth(widget) => {
widget.render_ref(area, buf);
}
Step::GitWarning(widget) => {
Step::TrustDirectory(widget) => {
widget.render_ref(area, buf);
}
Step::ContinueToChat(widget) => {

View File

@@ -0,0 +1,179 @@
use std::path::PathBuf;
use codex_core::config::set_project_trusted;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::colors::LIGHT_BLUE;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
use crate::app::ChatWidgetArgs;
use std::sync::Arc;
use std::sync::Mutex;
pub(crate) struct TrustDirectoryWidget {
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub is_git_repo: bool,
pub selection: Option<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
pub chat_widget_args: Arc<Mutex<ChatWidgetArgs>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TrustDirectorySelection {
Trust,
DontTrust,
}
impl WidgetRef for &TrustDirectoryWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::raw("> "),
Span::styled(
"You are running Codex in ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(self.cwd.to_string_lossy().to_string()),
]),
Line::from(""),
];
if self.is_git_repo {
lines.push(Line::from(
" Since this folder is version controlled, you may wish to allow Codex",
));
lines.push(Line::from(
" to work in this folder without asking for approval.",
));
} else {
lines.push(Line::from(
" Since this folder is not version controlled, we recommend requiring",
));
lines.push(Line::from(" approval of all edits and commands."));
}
lines.push(Line::from(""));
let create_option =
|idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> {
let is_selected = self.highlighted == option;
if is_selected {
Line::from(vec![
Span::styled(
format!("> {}. ", idx + 1),
Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
),
Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
])
} else {
Line::from(format!(" {}. {}", idx + 1, text))
}
};
if self.is_git_repo {
lines.push(create_option(
0,
TrustDirectorySelection::Trust,
"Yes, allow Codex to work in this folder without asking for approval",
));
lines.push(create_option(
1,
TrustDirectorySelection::DontTrust,
"No, ask me to approve edits and commands",
));
} else {
lines.push(create_option(
0,
TrustDirectorySelection::Trust,
"Allow Codex to work in this folder without asking for approval",
));
lines.push(create_option(
1,
TrustDirectorySelection::DontTrust,
"Require approval of edits and commands",
));
}
lines.push(Line::from(""));
if let Some(error) = &self.error {
lines.push(Line::from(format!(" {error}")).fg(Color::Red));
lines.push(Line::from(""));
}
lines.push(Line::from(" Press Enter to continue").add_modifier(Modifier::DIM));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
impl KeyboardHandler for TrustDirectoryWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlighted = TrustDirectorySelection::Trust;
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted = TrustDirectorySelection::DontTrust;
}
KeyCode::Char('1') => self.handle_trust(),
KeyCode::Char('2') => self.handle_dont_trust(),
KeyCode::Enter => match self.highlighted {
TrustDirectorySelection::Trust => self.handle_trust(),
TrustDirectorySelection::DontTrust => self.handle_dont_trust(),
},
_ => {}
}
}
}
impl StepStateProvider for TrustDirectoryWidget {
fn get_step_state(&self) -> StepState {
match self.selection {
Some(_) => StepState::Complete,
None => StepState::InProgress,
}
}
}
impl TrustDirectoryWidget {
fn handle_trust(&mut self) {
if let Err(e) = set_project_trusted(&self.codex_home, &self.cwd) {
tracing::error!("Failed to set project trusted: {e:?}");
self.error = Some(e.to_string());
// self.error = Some("Failed to set project trusted".to_string());
}
// Update the in-memory chat config for this session to a more permissive
// policy suitable for a trusted workspace.
if let Ok(mut args) = self.chat_widget_args.lock() {
args.config.approval_policy = AskForApproval::OnRequest;
args.config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
}
self.selection = Some(TrustDirectorySelection::Trust);
}
fn handle_dont_trust(&mut self) {
self.highlighted = TrustDirectorySelection::DontTrust;
self.selection = Some(TrustDirectorySelection::DontTrust);
}
}