[TUI] add external config migration prompt when start TUI (#17891)

- add a TUI startup migration prompt for external agent config
- support migrating external configs including config, skills, AGENTS.md
and plugins
- gate the prompt behind features.external_migrate (default false)

<img width="1037" height="480" alt="Screenshot 2026-04-14 at 9 29 14 PM"
src="https://github.com/user-attachments/assets/6060849b-03cb-429a-9c13-c7bb46ad2e65"
/>
<img width="713" height="183" alt="Screenshot 2026-04-14 at 9 29 26 PM"
src="https://github.com/user-attachments/assets/d13f177e-d4c4-479c-8736-ef29636081e1"
/>

---------

Co-authored-by: Eric Traut <etraut@openai.com>
This commit is contained in:
alexsong-oai
2026-04-17 17:58:32 -07:00
committed by GitHub
co-authored by Eric Traut
parent 370bed4bf4
commit 93ff798e5b
14 changed files with 2004 additions and 15 deletions
+55 -2
View File
@@ -392,6 +392,9 @@
"experimental_windows_sandbox": {
"type": "boolean"
},
"external_migration": {
"type": "boolean"
},
"fast_mode": {
"type": "boolean"
},
@@ -636,6 +639,39 @@
},
"type": "object"
},
"ExternalConfigMigrationPrompts": {
"additionalProperties": false,
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
"properties": {
"home": {
"description": "Tracks whether home-level external config migration prompts are hidden.",
"type": "boolean"
},
"home_last_prompted_at": {
"description": "Tracks the last time the home-level external config migration prompt was shown.",
"format": "int64",
"type": "integer"
},
"project_last_prompted_at": {
"additionalProperties": {
"format": "int64",
"type": "integer"
},
"default": {},
"description": "Tracks the last time a project-level external config migration prompt was shown.",
"type": "object"
},
"projects": {
"additionalProperties": {
"type": "boolean"
},
"default": {},
"description": "Tracks which project paths have opted out of external config migration prompts.",
"type": "object"
}
},
"type": "object"
},
"FeatureToml_for_MultiAgentV2ConfigToml": {
"anyOf": [
{
@@ -1153,8 +1189,22 @@
"type": "object"
},
"Notice": {
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
"additionalProperties": false,
"properties": {
"external_config_migration_prompts": {
"allOf": [
{
"$ref": "#/definitions/ExternalConfigMigrationPrompts"
}
],
"default": {
"home": null,
"home_last_prompted_at": null,
"project_last_prompted_at": {},
"projects": {}
},
"description": "Tracks scopes where external config migration prompts should be suppressed."
},
"hide_full_access_warning": {
"description": "Tracks whether the user has acknowledged the full access warning prompt.",
"type": "boolean"
@@ -2288,6 +2338,9 @@
"experimental_windows_sandbox": {
"type": "boolean"
},
"external_migration": {
"type": "boolean"
},
"fast_mode": {
"type": "boolean"
},
@@ -2866,4 +2919,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}
+77
View File
@@ -43,6 +43,14 @@ pub enum ConfigEdit {
SetWindowsWslSetupAcknowledged(bool),
/// Toggle the model migration prompt acknowledgement flag.
SetNoticeHideModelMigrationPrompt(String, bool),
/// Toggle the home external config migration prompt acknowledgement flag.
SetNoticeHideExternalConfigMigrationPromptHome(bool),
/// Record when the home external config migration prompt was last shown.
SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(i64),
/// Toggle the project external config migration prompt acknowledgement flag.
SetNoticeHideExternalConfigMigrationPromptProject(String, bool),
/// Record when the project external config migration prompt was last shown.
SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(String, i64),
/// Record that a migration prompt was shown for an old->new model mapping.
RecordModelMigrationSeen { from: String, to: String },
/// Replace the entire `[mcp_servers]` table.
@@ -421,6 +429,53 @@ impl ConfigDocument {
value(*acknowledged),
))
}
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self
.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
"home",
],
value(*acknowledged),
)),
ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => {
Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
"home_last_prompted_at",
],
value(*timestamp),
))
}
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
project,
acknowledged,
) => Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
"projects",
project.as_str(),
],
value(*acknowledged),
)),
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
project,
timestamp,
) => Ok(self.write_value(
Scope::Global,
&[
NOTICE_TABLE_KEY,
"external_config_migration_prompts",
"project_last_prompted_at",
project.as_str(),
],
value(*timestamp),
)),
ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
Scope::Global,
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
@@ -919,6 +974,28 @@ impl ConfigEditsBuilder {
self
}
pub fn set_hide_external_config_migration_prompt_home(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
acknowledged,
));
self
}
pub fn set_hide_external_config_migration_prompt_project(
mut self,
project: &str,
acknowledged: bool,
) -> Self {
self.edits.push(
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
project.to_string(),
acknowledged,
),
);
self
}
pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self {
self.edits.push(ConfigEdit::RecordModelMigrationSeen {
from: from.to_string(),
+124
View File
@@ -552,6 +552,130 @@ gpt-5 = "gpt-5.1"
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_hide_external_config_migration_prompt_home_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
true,
)],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
[notice.external_config_migration_prompts]
home = true
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_hide_external_config_migration_prompt_project_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
"/Users/alexsong/code/skills".to_string(),
true,
),
],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
[notice.external_config_migration_prompts.projects]
"/Users/alexsong/code/skills" = true
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_external_config_migration_prompt_home_last_prompted_at_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
[notice.external_config_migration_prompts]
home_last_prompted_at = 1760000000
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_set_external_config_migration_prompt_project_last_prompted_at_preserves_table() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"[notice]
existing = "value"
"#,
)
.expect("seed");
apply_blocking(
codex_home,
/*profile*/ None,
&[
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
"/Users/alexsong/code/skills".to_string(),
1_760_000_000,
),
],
)
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[notice]
existing = "value"
[notice.external_config_migration_prompts.project_last_prompted_at]
"/Users/alexsong/code/skills" = 1760000000
"#;
assert_eq!(contents, expected);
}
#[test]
fn blocking_replace_mcp_servers_round_trips() {
let tmp = tempdir().expect("tmpdir");
+2 -2
View File
@@ -273,7 +273,7 @@ impl ExternalAgentConfigService {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}",
"Migrate {} to {}",
source_agents_md.display(),
target_agents_md.display()
),
@@ -357,7 +357,7 @@ impl ExternalAgentConfigService {
Some(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!("Import enabled plugins from {}", source_settings.display()),
description: format!("Migrate enabled plugins from {}", source_settings.display()),
cwd,
details: Some(plugin_details),
})
@@ -74,7 +74,7 @@ async fn detect_home_lists_config_skills_and_agents_md() {
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}",
"Migrate {} to {}",
external_agent_home.join("CLAUDE.md").display(),
codex_home.join("AGENTS.md").display()
),
@@ -107,7 +107,7 @@ async fn detect_repo_lists_agents_md_for_each_cwd() {
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}",
"Migrate {} to {}",
repo_root.join("CLAUDE.md").display(),
repo_root.join("AGENTS.md").display(),
),
@@ -117,7 +117,7 @@ async fn detect_repo_lists_agents_md_for_each_cwd() {
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}",
"Migrate {} to {}",
repo_root.join("CLAUDE.md").display(),
repo_root.join("AGENTS.md").display(),
),
@@ -194,7 +194,7 @@ async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid(
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}",
"Migrate {} to {}",
repo_root.join(".claude").join("CLAUDE.md").display(),
repo_root.join("AGENTS.md").display(),
),
@@ -566,7 +566,7 @@ async fn detect_repo_prefers_non_empty_external_agent_agents_source() {
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}",
"Migrate {} to {}",
repo_root.join(".claude").join("CLAUDE.md").display(),
repo_root.join("AGENTS.md").display(),
),
@@ -650,7 +650,7 @@ async fn detect_home_lists_enabled_plugins_from_settings() {
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
@@ -710,7 +710,7 @@ enabled = true
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
"Migrate enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
@@ -868,7 +868,7 @@ enabled = true
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
"Migrate enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
@@ -1048,7 +1048,7 @@ source = "owner/debug-marketplace"
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
"Migrate enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),
@@ -1275,7 +1275,7 @@ async fn detect_home_supports_relative_external_agent_plugin_marketplace_path()
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
@@ -1426,7 +1426,7 @@ async fn detect_repo_supports_project_relative_external_agent_plugin_marketplace
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Import enabled plugins from {}",
"Migrate enabled plugins from {}",
repo_root.join(".claude").join("settings.json").display()
),
cwd: Some(repo_root),