tui: persist startup and onboarding config via app server

This commit is contained in:
Eric Traut
2026-05-21 16:23:03 -07:00
parent e8378c7f0c
commit 91d4b257b2
12 changed files with 254 additions and 116 deletions
+8 -4
View File
@@ -831,11 +831,15 @@ impl App {
);
let (mut chat_widget, initial_started_thread) = match session_selection {
SessionSelection::StartFresh | SessionSelection::Exit => {
// Only count a startup tooltip once the fresh thread can actually render it.
let startup_tooltip_override = prepare_startup_tooltip_override(
&mut app_server,
&mut config,
&available_models,
is_first_run,
)
.await;
spawn_startup_thread_start(&app_server, config.clone(), app_event_tx.clone());
// Count a startup tooltip once the initial chat widget can render it.
let startup_tooltip_override =
prepare_startup_tooltip_override(&mut config, &available_models, is_first_run)
.await;
let init = crate::chatwidget::ChatWidgetInit {
config: config.clone(),
frame_requester: tui.frame_requester(),
+44 -4
View File
@@ -515,9 +515,7 @@ impl App {
message,
"feature flag config write was overridden by effective config"
);
self.chat_widget.add_error_message(format!(
"Experimental feature changes were saved but not applied: {message}"
));
let mut show_overridden_message = true;
if let Some(effective_config) = self
.read_effective_config_after_overridden_write(
app_server,
@@ -529,6 +527,36 @@ impl App {
&effective_config,
&feature_updates_to_apply,
);
if feature_updates_to_apply
.iter()
.any(|(feature, enabled)| *feature == Feature::GuardianApproval && !*enabled)
&& !self.config.features.enabled(Feature::GuardianApproval)
{
show_overridden_message = false;
self.sync_active_thread_permission_settings_to_cached_session()
.await;
let op = AppCommand::override_turn_context(
/*cwd*/ None,
/*approval_policy*/ None,
Some(ApprovalsReviewer::User),
/*permission_profile*/ None,
/*active_permission_profile*/ None,
/*windows_sandbox_level*/ None,
/*model*/ None,
/*effort*/ None,
/*summary*/ None,
/*service_tier*/ None,
/*collaboration_mode*/ None,
/*personality*/ None,
);
let replay_state_op = ThreadEventStore::op_can_change_pending_replay_state(&op)
.then(|| op.clone());
let submitted = self.chat_widget.submit_op(op);
if submitted && let Some(op) = replay_state_op.as_ref() {
self.note_active_thread_outbound_op(op).await;
self.refresh_pending_thread_approvals().await;
}
}
self.sync_auto_review_runtime_state_from_effective_config(
&effective_config,
&feature_updates_to_apply,
@@ -537,6 +565,17 @@ impl App {
if windows_sandbox_changed {
self.propagate_windows_sandbox_turn_context();
}
if let Some(label) = permissions_history_label {
self.chat_widget.add_info_message(
format!("Permissions updated to {label}"),
/*hint*/ None,
);
}
}
if show_overridden_message {
self.chat_widget.add_error_message(format!(
"Experimental feature changes were saved but not applied: {message}"
));
}
return;
}
@@ -910,7 +949,8 @@ impl App {
return;
}
self.runtime_permission_profile_override = Some(permission_profile);
self.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&self.config));
self.sync_active_thread_permission_settings_to_cached_session()
.await;
+26 -16
View File
@@ -1661,10 +1661,12 @@ impl App {
.await;
}
AppEvent::PersistFullAccessWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_full_access_warning(/*acknowledged*/ true)
.apply()
.await
if let Err(err) = crate::config_update::write_config_value(
app_server.request_handle(),
"notice.hide_full_access_warning",
serde_json::json!(true),
)
.await
{
tracing::error!(
error = %err,
@@ -1676,10 +1678,12 @@ impl App {
}
}
AppEvent::PersistWorldWritableWarningAcknowledged => {
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_world_writable_warning(/*acknowledged*/ true)
.apply()
.await
if let Err(err) = crate::config_update::write_config_value(
app_server.request_handle(),
"notice.hide_world_writable_warning",
serde_json::json!(true),
)
.await
{
tracing::error!(
error = %err,
@@ -1691,10 +1695,12 @@ impl App {
}
}
AppEvent::PersistRateLimitSwitchPromptHidden => {
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.set_hide_rate_limit_model_nudge(/*acknowledged*/ true)
.apply()
.await
if let Err(err) = crate::config_update::write_config_value(
app_server.request_handle(),
"notice.hide_rate_limit_model_nudge",
serde_json::json!(true),
)
.await
{
tracing::error!(
error = %err,
@@ -1744,10 +1750,14 @@ impl App {
from_model,
to_model,
} => {
if let Err(err) = ConfigEditsBuilder::for_config(&self.config)
.record_model_migration_seen(from_model.as_str(), to_model.as_str())
.apply()
.await
let mut migration_update = serde_json::Map::new();
migration_update.insert(from_model, serde_json::json!(to_model));
if let Err(err) = crate::config_update::write_upsert_config_value(
app_server.request_handle(),
"notice.model_migrations",
serde_json::Value::Object(migration_update),
)
.await
{
tracing::error!(
error = %err,
+20 -7
View File
@@ -187,11 +187,11 @@ pub(super) fn select_model_availability_nux(
})
}
pub(super) async fn prepare_startup_tooltip_override(
config: &mut Config,
pub(super) fn next_startup_tooltip_override(
config: &Config,
available_models: &[ModelPreset],
is_first_run: bool,
) -> Option<String> {
) -> Option<(StartupTooltipOverride, HashMap<String, u32>)> {
if is_first_run || !config.show_tooltips {
return None;
}
@@ -208,11 +208,24 @@ pub(super) async fn prepare_startup_tooltip_override(
let next_count = shown_count.saturating_add(1);
let mut updated_shown_count = config.model_availability_nux.shown_count.clone();
updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count);
Some((tooltip_override, updated_shown_count))
}
if let Err(err) = ConfigEditsBuilder::for_config(config)
.set_model_availability_nux_count(&updated_shown_count)
.apply()
.await
pub(super) async fn prepare_startup_tooltip_override(
app_server: &mut AppServerSession,
config: &mut Config,
available_models: &[ModelPreset],
is_first_run: bool,
) -> Option<String> {
let (tooltip_override, updated_shown_count) =
next_startup_tooltip_override(config, available_models, is_first_run)?;
if let Err(err) = crate::config_update::write_config_value(
app_server.request_handle(),
"tui.model_availability_nux",
serde_json::json!(updated_shown_count),
)
.await
{
tracing::error!(
error = %err,
+7 -19
View File
@@ -175,9 +175,9 @@ fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() {
}
#[tokio::test]
async fn prepare_startup_tooltip_override_persists_model_availability_nux_count() {
async fn next_startup_tooltip_override_advances_model_availability_nux_count() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
@@ -194,24 +194,12 @@ async fn prepare_startup_tooltip_override_persists_model_availability_nux_count(
message: "gpt-5.4 is available".to_string(),
});
let tooltip =
prepare_startup_tooltip_override(&mut config, &presets, /*is_first_run*/ false).await;
let (tooltip, shown_count) =
next_startup_tooltip_override(&config, &presets, /*is_first_run*/ false)
.expect("tooltip update");
assert_eq!(tooltip.as_deref(), Some("gpt-5.4 is available"));
assert_eq!(
config.model_availability_nux.shown_count,
HashMap::from([("gpt-5.4".to_string(), 1)])
);
let reloaded = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("reloaded config");
assert_eq!(
reloaded.model_availability_nux.shown_count,
HashMap::from([("gpt-5.4".to_string(), 1)])
);
assert_eq!(tooltip.message, "gpt-5.4 is available");
assert_eq!(shown_count, HashMap::from([("gpt-5.4".to_string(), 1)]));
}
#[tokio::test]
+67
View File
@@ -15,12 +15,16 @@ use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SkillsConfigWriteParams;
use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_config::loader::project_trust_key;
use codex_exec_server::LOCAL_FS;
use codex_features::FEATURES;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use serde_json::Value as JsonValue;
use std::path::PathBuf;
use uuid::Uuid;
pub(crate) fn replace_config_value(key_path: impl Into<String>, value: JsonValue) -> ConfigEdit {
@@ -31,6 +35,14 @@ pub(crate) fn replace_config_value(key_path: impl Into<String>, value: JsonValue
}
}
pub(crate) fn upsert_config_value(key_path: impl Into<String>, value: JsonValue) -> ConfigEdit {
ConfigEdit {
key_path: key_path.into(),
value,
merge_strategy: MergeStrategy::Upsert,
}
}
pub(crate) fn clear_config_value(key_path: impl Into<String>) -> ConfigEdit {
replace_config_value(key_path, JsonValue::Null)
}
@@ -149,6 +161,61 @@ pub(crate) fn build_memory_settings_edits(
]
}
pub(crate) async fn write_oss_provider(
request_handle: AppServerRequestHandle,
provider: String,
) -> Result<()> {
write_config_value(request_handle, "oss_provider", serde_json::json!(provider)).await
}
pub(crate) async fn write_trusted_project(
request_handle: AppServerRequestHandle,
cwd: &AbsolutePathBuf,
) -> Result<PathBuf> {
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), cwd)
.await
.map(Into::into)
.unwrap_or_else(|| cwd.to_path_buf());
let mut project_update = serde_json::Map::new();
project_update.insert(
project_trust_key(trust_target.as_path()),
serde_json::json!({ "trust_level": "trusted" }),
);
write_upsert_config_value(
request_handle,
"projects",
serde_json::Value::Object(project_update),
)
.await
.wrap_err_with(|| {
format!(
"failed to persist trusted project {}",
trust_target.display()
)
})?;
Ok(trust_target)
}
pub(crate) async fn write_config_value(
request_handle: AppServerRequestHandle,
key_path: impl Into<String>,
value: JsonValue,
) -> Result<()> {
write_config_batch(request_handle, vec![replace_config_value(key_path, value)])
.await
.map(|_| ())
}
pub(crate) async fn write_upsert_config_value(
request_handle: AppServerRequestHandle,
key_path: impl Into<String>,
value: JsonValue,
) -> Result<()> {
write_config_batch(request_handle, vec![upsert_config_value(key_path, value)])
.await
.map(|_| ())
}
pub(crate) async fn write_config_batch(
request_handle: AppServerRequestHandle,
edits: Vec<ConfigEdit>,
@@ -4,8 +4,6 @@ use crate::external_agent_config_migration::run_external_agent_config_migration_
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
use crate::tui;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
@@ -120,40 +118,43 @@ fn unix_seconds_now() -> i64 {
}
async fn persist_external_agent_config_migration_prompt_shown(
app_server: &mut AppServerSession,
config: &mut Config,
items: &[ExternalAgentConfigMigrationItem],
now_unix_seconds: i64,
) -> Result<()> {
let mut edits = Vec::new();
let mut prompt_update = serde_json::Map::new();
if items.iter().any(|item| item.cwd.is_none()) {
edits.push(
ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(now_unix_seconds),
prompt_update.insert(
"home_last_prompted_at".to_string(),
serde_json::json!(now_unix_seconds),
);
}
for project in items
let project_updates = items
.iter()
.filter_map(|item| item.cwd.as_deref())
.map(external_config_migration_project_key)
{
edits.push(
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
project,
now_unix_seconds,
),
.map(|project| (project, serde_json::json!(now_unix_seconds)))
.collect::<serde_json::Map<_, _>>();
if !project_updates.is_empty() {
prompt_update.insert(
"project_last_prompted_at".to_string(),
serde_json::Value::Object(project_updates),
);
}
if edits.is_empty() {
if prompt_update.is_empty() {
return Ok(());
}
ConfigEditsBuilder::for_config(config)
.with_edits(edits)
.apply()
.await
.map_err(|err| color_eyre::eyre::eyre!("{err}"))
.wrap_err("Failed to save external config migration prompt timestamp")?;
crate::config_update::write_upsert_config_value(
app_server.request_handle(),
"notice.external_config_migration_prompts",
serde_json::Value::Object(prompt_update),
)
.await
.wrap_err("Failed to save external config migration prompt timestamp")?;
if items.iter().any(|item| item.cwd.is_none()) {
config
@@ -177,6 +178,7 @@ async fn persist_external_agent_config_migration_prompt_shown(
}
async fn persist_external_agent_config_migration_prompt_dismissal(
app_server: &mut AppServerSession,
config: &mut Config,
items: &[ExternalAgentConfigMigrationItem],
) -> Result<()> {
@@ -187,7 +189,7 @@ async fn persist_external_agent_config_migration_prompt_dismissal(
.map(external_config_migration_project_key)
.collect::<BTreeSet<_>>();
let mut edits = Vec::new();
let mut prompt_update = serde_json::Map::new();
if hide_home
&& !config
.notices
@@ -195,10 +197,9 @@ async fn persist_external_agent_config_migration_prompt_dismissal(
.home
.unwrap_or(false)
{
edits.push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
true,
));
prompt_update.insert("home".to_string(), serde_json::json!(true));
}
let mut project_updates = serde_json::Map::new();
for project in &projects {
if !config
.notices
@@ -208,25 +209,27 @@ async fn persist_external_agent_config_migration_prompt_dismissal(
.copied()
.unwrap_or(false)
{
edits.push(
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
project.clone(),
true,
),
);
project_updates.insert(project.clone(), serde_json::json!(true));
}
}
if !project_updates.is_empty() {
prompt_update.insert(
"projects".to_string(),
serde_json::Value::Object(project_updates),
);
}
if edits.is_empty() {
if prompt_update.is_empty() {
return Ok(());
}
ConfigEditsBuilder::for_config(config)
.with_edits(edits)
.apply()
.await
.map_err(|err| color_eyre::eyre::eyre!("{err}"))
.wrap_err("Failed to save external config migration prompt preference")?;
crate::config_update::write_upsert_config_value(
app_server.request_handle(),
"notice.external_config_migration_prompts",
serde_json::Value::Object(prompt_update),
)
.await
.wrap_err("Failed to save external config migration prompt preference")?;
if hide_home {
config.notices.external_config_migration_prompts.home = Some(true);
@@ -286,6 +289,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt_if_needed(
}
if let Err(err) = persist_external_agent_config_migration_prompt_shown(
app_server,
config,
&detected_items,
now_unix_seconds,
@@ -345,6 +349,7 @@ pub(crate) async fn handle_external_agent_config_migration_prompt_if_needed(
}
ExternalAgentConfigMigrationOutcome::SkipForever => {
match persist_external_agent_config_migration_prompt_dismissal(
app_server,
config,
&detected_items,
)
+36 -1
View File
@@ -993,6 +993,7 @@ pub async fn run_main(
)
.await;
let mut oss_provider_to_persist = None;
let model_provider_override = if cli.oss {
let resolved = resolve_oss_provider(
cli.oss_provider.as_deref(),
@@ -1004,12 +1005,15 @@ pub async fn run_main(
Some(provider)
} else {
// No provider configured, prompt the user
let provider = oss_selection::select_oss_provider(&codex_home).await?;
let (provider, persist) = oss_selection::select_oss_provider().await?;
if provider == "__CANCELLED__" {
return Err(std::io::Error::other(
"OSS provider selection was cancelled by user",
));
}
if persist {
oss_provider_to_persist = Some(provider.clone());
}
Some(provider)
}
} else {
@@ -1263,6 +1267,7 @@ pub async fn run_main(
log_db,
state_db,
environment_manager,
oss_provider_to_persist,
)
.await
.map_err(|err| std::io::Error::other(err.to_string()))
@@ -1284,6 +1289,7 @@ async fn run_ratatui_app(
log_db: Option<log_db::LogDbLayer>,
state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
oss_provider_to_persist: Option<String>,
) -> color_eyre::Result<AppExitInfo> {
let uses_remote_workspace = app_server_target.uses_remote_workspace();
color_eyre::install()?;
@@ -1357,6 +1363,16 @@ async fn run_ratatui_app(
}
.with_remote_cwd_override(remote_cwd_override.clone());
let mut app_server = Some(app_server_session);
if let Some(provider) = oss_provider_to_persist {
let Some(app_server) = app_server.as_ref() else {
unreachable!("app server should exist before OSS provider persistence");
};
if let Err(err) =
crate::config_update::write_oss_provider(app_server.request_handle(), provider).await
{
tracing::warn!(error = %err, "failed to persist OSS provider preference");
}
}
let should_show_trust_screen_flag =
!uses_remote_workspace && should_show_trust_screen(&initial_config);
@@ -1405,6 +1421,25 @@ async fn run_ratatui_app(
exit_reason: ExitReason::UserRequested,
});
}
if matches!(
onboarding_result.directory_trust_decision,
Some(crate::onboarding::TrustDirectorySelection::Trust)
) {
let Some(app_server) = app_server.as_ref() else {
unreachable!("app server should exist while onboarding is active");
};
if let Err(err) = crate::config_update::write_trusted_project(
app_server.request_handle(),
&initial_config.cwd,
)
.await
{
tracing::warn!(
error = %err,
"failed to persist trusted project state through app server"
);
}
}
trust_decision_was_made = onboarding_result.directory_trust_decision.is_some();
// If this onboarding run included the login step, always refresh cloud requirements and
// rebuild config. This avoids missing newly available cloud requirements due to login
+1
View File
@@ -4,4 +4,5 @@ pub(crate) mod onboarding_screen;
mod trust_directory;
pub(crate) use auth::mark_underlined_hyperlink;
pub(crate) use auth::mark_url_hyperlink;
pub(crate) use trust_directory::TrustDirectorySelection;
mod welcome;
@@ -111,7 +111,6 @@ impl OnboardingScreen {
config,
} = args;
let cwd = config.cwd.to_path_buf();
let codex_home = config.codex_home.to_path_buf();
let forced_login_method = config.forced_login_method;
let mut steps: Vec<Step> = Vec::new();
steps.push(Step::Welcome(WelcomeWidget::new(
@@ -154,7 +153,6 @@ impl OnboardingScreen {
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd,
trust_target,
codex_home,
show_windows_create_sandbox_hint,
should_quit: false,
selection: None,
@@ -1,7 +1,5 @@
use std::path::PathBuf;
use crate::legacy_core::config::set_project_trust_level;
use codex_protocol::config_types::TrustLevel;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
@@ -24,7 +22,6 @@ use crate::selection_list::selection_option_row;
use super::onboarding_screen::StepState;
pub(crate) struct TrustDirectoryWidget {
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub trust_target: PathBuf,
pub show_windows_create_sandbox_hint: bool,
@@ -166,12 +163,6 @@ impl StepStateProvider for TrustDirectoryWidget {
impl TrustDirectoryWidget {
fn handle_trust(&mut self) {
let target = self.trust_target.clone();
if let Err(e) = set_project_trust_level(&self.codex_home, &target, TrustLevel::Trusted) {
tracing::error!("Failed to set project trusted: {e:?}");
self.error = Some(format!("Failed to set trust for {}: {e}", target.display()));
}
self.selection = Some(TrustDirectorySelection::Trust);
}
@@ -197,13 +188,10 @@ mod tests {
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn release_event_does_not_change_selection() {
let codex_home = TempDir::new().expect("temp home");
let mut widget = TrustDirectoryWidget {
codex_home: codex_home.path().to_path_buf(),
cwd: PathBuf::from("."),
trust_target: PathBuf::from("."),
show_windows_create_sandbox_hint: false,
@@ -227,9 +215,7 @@ mod tests {
#[test]
fn renders_snapshot_for_git_repo() {
let codex_home = TempDir::new().expect("temp home");
let widget = TrustDirectoryWidget {
codex_home: codex_home.path().to_path_buf(),
cwd: PathBuf::from("/workspace/project"),
trust_target: PathBuf::from("/workspace/project"),
show_windows_create_sandbox_hint: false,
+4 -13
View File
@@ -4,7 +4,6 @@ use std::sync::LazyLock;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::legacy_core::config::set_default_oss_provider;
use codex_model_provider_info::DEFAULT_LMSTUDIO_PORT;
use codex_model_provider_info::DEFAULT_OLLAMA_PORT;
use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID;
@@ -309,7 +308,7 @@ fn get_status_symbol_and_color(status: &ProviderStatus) -> (&'static str, Color)
}
}
pub async fn select_oss_provider(codex_home: &std::path::Path) -> io::Result<String> {
pub async fn select_oss_provider() -> io::Result<(String, bool)> {
// Check provider statuses first
let lmstudio_status = check_lmstudio_status().await;
let ollama_status = check_ollama_status().await;
@@ -318,11 +317,11 @@ pub async fn select_oss_provider(codex_home: &std::path::Path) -> io::Result<Str
match (&lmstudio_status, &ollama_status) {
(ProviderStatus::Running, ProviderStatus::NotRunning) => {
let provider = LMSTUDIO_OSS_PROVIDER_ID.to_string();
return Ok(provider);
return Ok((provider, /*persist*/ false));
}
(ProviderStatus::NotRunning, ProviderStatus::Running) => {
let provider = OLLAMA_OSS_PROVIDER_ID.to_string();
return Ok(provider);
return Ok((provider, /*persist*/ false));
}
_ => {
// Both running or both not running - show UI
@@ -353,15 +352,7 @@ pub async fn select_oss_provider(codex_home: &std::path::Path) -> io::Result<Str
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
// If the user manually selected an OSS provider, we save it as the
// default one to use later.
if let Ok(ref provider) = result
&& let Err(e) = set_default_oss_provider(codex_home, provider)
{
tracing::warn!("Failed to save OSS provider preference: {e}");
}
result
result.map(|provider| (provider, /*persist*/ true))
}
async fn check_lmstudio_status() -> ProviderStatus {