[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:
24
codex-rs/Cargo.lock
generated
24
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 auto‑approved.
|
||||
/// 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
mod auth;
|
||||
mod continue_to_chat;
|
||||
mod git_warning;
|
||||
pub mod onboarding_screen;
|
||||
mod trust_directory;
|
||||
mod welcome;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
179
codex-rs/tui/src/onboarding/trust_directory.rs
Normal file
179
codex-rs/tui/src/onboarding/trust_directory.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user