From 93ff798e5b29acdf659935cac6701f8e7dfe0de1 Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Fri, 17 Apr 2026 17:58:32 -0700 Subject: [PATCH] [TUI] add external config migration prompt when start TUI (#17891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) Screenshot 2026-04-14 at 9 29 14 PM Screenshot 2026-04-14 at 9 29 26 PM --------- Co-authored-by: Eric Traut --- codex-rs/config/src/types.rs | 19 + codex-rs/core/config.schema.json | 57 +- codex-rs/core/src/config/edit.rs | 77 ++ codex-rs/core/src/config/edit_tests.rs | 124 ++ codex-rs/core/src/external_agent_config.rs | 4 +- .../core/src/external_agent_config_tests.rs | 22 +- codex-rs/features/src/lib.rs | 12 + codex-rs/features/src/tests.rs | 17 + codex-rs/tui/src/app.rs | 38 + codex-rs/tui/src/app_server_session.rs | 30 + .../src/external_agent_config_migration.rs | 1024 +++++++++++++++++ ...external_agent_config_migration_startup.rs | 567 +++++++++ codex-rs/tui/src/lib.rs | 3 + ...xternal_agent_config_migration_prompt.snap | 25 + 14 files changed, 2004 insertions(+), 15 deletions(-) create mode 100644 codex-rs/tui/src/external_agent_config_migration.rs create mode 100644 codex-rs/tui/src/external_agent_config_migration_startup.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 243bc0d6d..ae62216d2 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -592,7 +592,23 @@ const fn default_true() -> bool { /// 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. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ExternalConfigMigrationPrompts { + /// Tracks whether home-level external config migration prompts are hidden. + pub home: Option, + /// Tracks the last time the home-level external config migration prompt was shown. + pub home_last_prompted_at: Option, + /// Tracks which project paths have opted out of external config migration prompts. + #[serde(default)] + pub projects: BTreeMap, + /// Tracks the last time a project-level external config migration prompt was shown. + #[serde(default)] + pub project_last_prompted_at: BTreeMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] pub struct Notice { /// Tracks whether the user has acknowledged the full access warning prompt. pub hide_full_access_warning: Option, @@ -608,6 +624,9 @@ pub struct Notice { /// Tracks acknowledged model migrations as old->new model slug mappings. #[serde(default)] pub model_migrations: BTreeMap, + /// Tracks scopes where external config migration prompts should be suppressed. + #[serde(default)] + pub external_config_migration_prompts: ExternalConfigMigrationPrompts, } pub use crate::skills_config::BundledSkillsConfig; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index f0fbe8994..5135e4b0d 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -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" -} \ No newline at end of file +} diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 5adc0f998..8f336f7ff 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -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(), diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 4f340d89b..84a4a34ea 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -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"); diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs index 73aba5af1..db5894e95 100644 --- a/codex-rs/core/src/external_agent_config.rs +++ b/codex-rs/core/src/external_agent_config.rs @@ -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), }) diff --git a/codex-rs/core/src/external_agent_config_tests.rs b/codex-rs/core/src/external_agent_config_tests.rs index 4fd81351a..3b427f6bf 100644 --- a/codex-rs/core/src/external_agent_config_tests.rs +++ b/codex-rs/core/src/external_agent_config_tests.rs @@ -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), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 9a8862c27..f811f6aa1 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -156,6 +156,8 @@ pub enum Feature { ToolSuggest, /// Enable plugins. Plugins, + /// Show the startup prompt for migrating external agent config into Codex. + ExternalMigration, /// Allow the model to invoke the built-in image generation tool. ImageGeneration, /// Allow prompting and installing missing MCP dependencies. @@ -844,6 +846,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ExternalMigration, + key: "external_migration", + stage: Stage::Experimental { + name: "External migration", + menu_description: "Show a startup prompt when Codex detects migratable external agent config for this machine or project.", + announcement: "", + }, + default_enabled: false, + }, FeatureSpec { id: Feature::ImageGeneration, key: "image_generation", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index a561d0ee3..18b030f88 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -103,6 +103,23 @@ fn guardian_approval_is_experimental_and_user_toggleable() { assert_eq!(Feature::GuardianApproval.default_enabled(), false); } +#[test] +fn external_migration_is_experimental_and_disabled_by_default() { + let spec = Feature::ExternalMigration.info(); + let stage = spec.stage; + + assert!(matches!(stage, Stage::Experimental { .. })); + assert_eq!(stage.experimental_menu_name(), Some("External migration")); + assert_eq!( + stage.experimental_menu_description(), + Some( + "Show a startup prompt when Codex detects migratable external agent config for this machine or project." + ) + ); + assert_eq!(stage.experimental_announcement(), None); + assert_eq!(Feature::ExternalMigration.default_enabled(), false); +} + #[test] fn request_permissions_is_under_development() { assert_eq!( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ddffc46d0..925dba245 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -28,6 +28,8 @@ use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; use crate::exec_command::split_command_string; use crate::exec_command::strip_bash_lc_and_escape; +use crate::external_agent_config_migration_startup::ExternalAgentConfigMigrationStartupOutcome; +use crate::external_agent_config_migration_startup::handle_external_agent_config_migration_prompt_if_needed; use crate::external_editor; use crate::file_search::FileSearchManager; use crate::history_cell; @@ -3861,6 +3863,7 @@ impl App { session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, + entered_trust_nux: bool, should_prompt_windows_sandbox_nux_at_startup: bool, remote_app_server_url: Option, remote_app_server_auth_token: Option, @@ -3878,6 +3881,38 @@ impl App { let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; + let external_agent_config_migration_outcome = + handle_external_agent_config_migration_prompt_if_needed( + tui, + &mut app_server, + &mut config, + &cli_kv_overrides, + &harness_overrides, + entered_trust_nux, + ) + .await?; + let external_agent_config_migration_message = match external_agent_config_migration_outcome + { + ExternalAgentConfigMigrationStartupOutcome::Continue { success_message } => { + success_message + } + ExternalAgentConfigMigrationStartupOutcome::ExitRequested => { + app_server + .shutdown() + .await + .inspect_err(|err| { + tracing::warn!("app-server shutdown failed: {err}"); + }) + .ok(); + return Ok(AppExitInfo { + token_usage: TokenUsage::default(), + thread_id: None, + thread_name: None, + update_action: None, + exit_reason: ExitReason::UserRequested, + }); + } + }; let bootstrap = app_server.bootstrap(&config).await?; let mut model = bootstrap.default_model; let available_models = bootstrap.available_models; @@ -4048,6 +4083,9 @@ impl App { (ChatWidget::new_with_app_event(init), Some(forked)) } }; + if let Some(message) = external_agent_config_migration_message { + chat_widget.add_info_message(message, /*hint*/ None); + } chat_widget .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 7db3dd7d3..d31d2e973 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -14,6 +14,11 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ExternalAgentConfigDetectParams; +use codex_app_server_protocol::ExternalAgentConfigDetectResponse; +use codex_app_server_protocol::ExternalAgentConfigImportParams; +use codex_app_server_protocol::ExternalAgentConfigImportResponse; +use codex_app_server_protocol::ExternalAgentConfigMigrationItem; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::GetAccountResponse; @@ -286,6 +291,31 @@ impl AppServerSession { .wrap_err("account/read failed during TUI bootstrap") } + pub(crate) async fn external_agent_config_detect( + &mut self, + params: ExternalAgentConfigDetectParams, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params }) + .await + .wrap_err("externalAgentConfig/detect failed during TUI startup") + } + + pub(crate) async fn external_agent_config_import( + &mut self, + migration_items: Vec, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ExternalAgentConfigImport { + request_id, + params: ExternalAgentConfigImportParams { migration_items }, + }) + .await + .wrap_err("externalAgentConfig/import failed during TUI startup") + } + pub(crate) async fn next_event(&mut self) -> Option { self.client.next_event().await } diff --git a/codex-rs/tui/src/external_agent_config_migration.rs b/codex-rs/tui/src/external_agent_config_migration.rs new file mode 100644 index 000000000..2b667b802 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration.rs @@ -0,0 +1,1024 @@ +use crate::diff_render::display_path_for; +use crate::key_hint; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::render::Insets; +use crate::render::RectExt as _; +use crate::selection_list::selection_option_row_with_dim; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use codex_app_server_protocol::ExternalAgentConfigMigrationItem; +use codex_app_server_protocol::PluginsMigration; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Constraint; +use ratatui::layout::Layout; +use ratatui::layout::Rect; +use ratatui::prelude::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; +use tokio_stream::StreamExt; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ExternalAgentConfigMigrationOutcome { + Proceed(Vec), + Skip, + SkipForever, + Exit, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FocusArea { + Items, + Actions, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ActionMenuOption { + Proceed, + Skip, + SkipForever, +} + +impl ActionMenuOption { + fn label(self) -> &'static str { + match self { + Self::Proceed => "Proceed with selected", + Self::Skip => "Skip for now", + Self::SkipForever => "Don't ask again", + } + } + + fn previous(self) -> Option { + match self { + Self::Proceed => None, + Self::Skip => Some(Self::Proceed), + Self::SkipForever => Some(Self::Skip), + } + } + + fn next(self) -> Option { + match self { + Self::Proceed => Some(Self::Skip), + Self::Skip => Some(Self::SkipForever), + Self::SkipForever => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct MigrationSelection { + item: ExternalAgentConfigMigrationItem, + enabled: bool, +} + +struct RenderLineEntry { + item_idx: Option, + kind: RenderLineKind, + line: Line<'static>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RenderLineKind { + Section, + Item, + ItemDetail, +} + +pub(crate) async fn run_external_agent_config_migration_prompt( + tui: &mut Tui, + items: &[ExternalAgentConfigMigrationItem], + selected_items: &[ExternalAgentConfigMigrationItem], + error: Option<&str>, +) -> ExternalAgentConfigMigrationOutcome { + let mut screen = ExternalAgentConfigMigrationScreen::new( + tui.frame_requester(), + items, + selected_items, + error.map(str::to_owned), + ); + + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + }); + + let events = tui.event_stream(); + tokio::pin!(events); + + while !screen.is_done() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Paste(_) => {} + TuiEvent::Draw => { + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + }); + } + } + } else { + screen.skip(); + break; + } + } + + screen.outcome() +} + +struct ExternalAgentConfigMigrationScreen { + request_frame: FrameRequester, + items: Vec, + selected_item_idx: Option, + scroll_top: usize, + focus: FocusArea, + highlighted_action: ActionMenuOption, + done: bool, + outcome: ExternalAgentConfigMigrationOutcome, + error: Option, +} + +impl ExternalAgentConfigMigrationScreen { + fn proceed_enabled(&self) -> bool { + self.selected_count() > 0 + } + + fn first_available_action(&self) -> ActionMenuOption { + if self.proceed_enabled() { + ActionMenuOption::Proceed + } else { + ActionMenuOption::Skip + } + } + + fn previous_available_action(&self, action: ActionMenuOption) -> Option { + let mut candidate = action.previous(); + while let Some(option) = candidate { + if option != ActionMenuOption::Proceed || self.proceed_enabled() { + return Some(option); + } + candidate = option.previous(); + } + None + } + + fn next_available_action(&self, action: ActionMenuOption) -> Option { + let mut candidate = action.next(); + while let Some(option) = candidate { + if option != ActionMenuOption::Proceed || self.proceed_enabled() { + return Some(option); + } + candidate = option.next(); + } + None + } + + fn normalize_highlighted_action(&mut self) { + if self.highlighted_action == ActionMenuOption::Proceed && !self.proceed_enabled() { + self.highlighted_action = self.first_available_action(); + } + } + + fn display_description(item: &ExternalAgentConfigMigrationItem) -> String { + let Some(cwd) = item.cwd.as_deref() else { + return item.description.clone(); + }; + + fn reformat_description( + description: &str, + prefix: &str, + separator: &str, + cwd: &std::path::Path, + ) -> Option { + let remainder = description.strip_prefix(prefix)?; + let (left, right) = remainder.split_once(separator)?; + Some(format!( + "{prefix}{}{}{}", + display_path_for(std::path::Path::new(left), cwd), + separator, + display_path_for(std::path::Path::new(right), cwd) + )) + } + + if let Some(reformatted) = + reformat_description(&item.description, "Migrate ", " into ", cwd) + { + return reformatted; + } + + if let Some(reformatted) = + reformat_description(&item.description, "Migrate skills from ", " to ", cwd) + { + return reformatted; + } + + if let Some(reformatted) = reformat_description(&item.description, "Migrate ", " to ", cwd) + { + return reformatted; + } + + if let Some(reformatted) = reformat_description(&item.description, "Import ", " to ", cwd) { + return reformatted; + } + + if let Some(source) = item + .description + .strip_prefix("Migrate enabled plugins from ") + { + let description = format!( + "Migrate enabled plugins from {}", + display_path_for(std::path::Path::new(source), cwd) + ); + if let Some(details) = &item.details { + let marketplace_count = details.plugins.len(); + let plugin_count = details + .plugins + .iter() + .map(|plugin_group| plugin_group.plugin_names.len()) + .sum::(); + return format!( + "{description} ({marketplace_count} {}, {plugin_count} {})", + if marketplace_count == 1 { + "marketplace" + } else { + "marketplaces" + }, + if plugin_count == 1 { + "plugin" + } else { + "plugins" + } + ); + } + return description; + } + + item.description.clone() + } + + fn new( + request_frame: FrameRequester, + items: &[ExternalAgentConfigMigrationItem], + selected_items: &[ExternalAgentConfigMigrationItem], + error: Option, + ) -> Self { + let items = items + .iter() + .cloned() + .map(|item| MigrationSelection { + enabled: selected_items.contains(&item), + item, + }) + .collect::>(); + let selected_item_idx = (!items.is_empty()).then_some(0); + Self { + request_frame, + items, + selected_item_idx, + scroll_top: 0, + focus: FocusArea::Items, + highlighted_action: ActionMenuOption::Proceed, + done: false, + outcome: ExternalAgentConfigMigrationOutcome::Skip, + error, + } + } + + fn plugin_detail_lines(plugin_groups: &[PluginsMigration]) -> Vec> { + let mut lines = plugin_groups + .iter() + .take(3) + .map(|plugin_group| { + let mut plugin_names = plugin_group + .plugin_names + .iter() + .take(2) + .cloned() + .collect::>(); + let hidden_plugin_count = plugin_group + .plugin_names + .len() + .saturating_sub(plugin_names.len()); + if hidden_plugin_count > 0 { + plugin_names.push(format!("+{hidden_plugin_count} more")); + } + Line::from(format!( + " • {}: {}", + plugin_group.marketplace_name, + plugin_names.join(", ") + )) + }) + .collect::>(); + let hidden_marketplace_count = plugin_groups.len().saturating_sub(lines.len()); + if hidden_marketplace_count > 0 { + lines.push(Line::from(format!( + " • +{hidden_marketplace_count} more marketplaces" + ))); + } + lines + } + + fn is_done(&self) -> bool { + self.done + } + + fn outcome(&self) -> ExternalAgentConfigMigrationOutcome { + self.outcome.clone() + } + + fn finish_with(&mut self, outcome: ExternalAgentConfigMigrationOutcome) { + self.outcome = outcome; + self.done = true; + self.request_frame.schedule_frame(); + } + + fn proceed(&mut self) { + let selected = self.selected_items(); + if selected.is_empty() { + self.error = Some("Select at least one item or choose a skip option.".to_string()); + self.request_frame.schedule_frame(); + return; + } + + self.finish_with(ExternalAgentConfigMigrationOutcome::Proceed(selected)); + } + + fn skip(&mut self) { + self.finish_with(ExternalAgentConfigMigrationOutcome::Skip); + } + + fn skip_forever(&mut self) { + self.finish_with(ExternalAgentConfigMigrationOutcome::SkipForever); + } + + fn exit(&mut self) { + self.finish_with(ExternalAgentConfigMigrationOutcome::Exit); + } + + fn selected_items(&self) -> Vec { + self.items + .iter() + .filter(|item| item.enabled) + .map(|item| item.item.clone()) + .collect() + } + + fn selected_count(&self) -> usize { + self.items.iter().filter(|item| item.enabled).count() + } + + fn set_all_enabled(&mut self, enabled: bool) { + for item in &mut self.items { + item.enabled = enabled; + } + self.error = None; + self.normalize_highlighted_action(); + self.request_frame.schedule_frame(); + } + + fn toggle_selected_item(&mut self) { + if self.focus != FocusArea::Items { + return; + } + let Some(selected_idx) = self.selected_item_idx else { + return; + }; + let Some(item) = self.items.get_mut(selected_idx) else { + return; + }; + + item.enabled = !item.enabled; + self.error = None; + self.normalize_highlighted_action(); + self.request_frame.schedule_frame(); + } + + fn move_up(&mut self) { + match self.focus { + FocusArea::Items => match self.selected_item_idx { + Some(0) => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::SkipForever; + } + Some(idx) => { + self.selected_item_idx = Some(idx.saturating_sub(1)); + } + None => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::SkipForever; + } + }, + FocusArea::Actions => { + if let Some(previous) = self.previous_available_action(self.highlighted_action) { + self.highlighted_action = previous; + } else { + self.focus = FocusArea::Items; + if !self.items.is_empty() { + self.selected_item_idx = Some(self.items.len() - 1); + } + } + } + } + self.ensure_selected_item_visible(); + self.request_frame.schedule_frame(); + } + + fn move_down(&mut self) { + match self.focus { + FocusArea::Items => match self.selected_item_idx { + Some(idx) if idx + 1 < self.items.len() => { + self.selected_item_idx = Some(idx + 1); + } + _ => { + self.focus = FocusArea::Actions; + self.highlighted_action = self.first_available_action(); + } + }, + FocusArea::Actions => { + if let Some(next) = self.next_available_action(self.highlighted_action) { + self.highlighted_action = next; + } else { + self.focus = FocusArea::Items; + if !self.items.is_empty() { + self.selected_item_idx = Some(0); + } + } + } + } + self.ensure_selected_item_visible(); + self.request_frame.schedule_frame(); + } + + fn confirm_selection(&mut self) { + match self.focus { + FocusArea::Items => self.toggle_selected_item(), + FocusArea::Actions => match self.highlighted_action { + ActionMenuOption::Proceed => self.proceed(), + ActionMenuOption::Skip => self.skip(), + ActionMenuOption::SkipForever => self.skip_forever(), + }, + } + } + + fn handle_key(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + + if is_ctrl_exit_combo(key_event) { + self.exit(); + return; + } + + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => self.move_up(), + KeyCode::Down | KeyCode::Char('j') => self.move_down(), + KeyCode::Char('1') => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::Proceed; + self.proceed(); + } + KeyCode::Char('2') => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::Skip; + self.skip(); + } + KeyCode::Char('3') => { + self.focus = FocusArea::Actions; + self.highlighted_action = ActionMenuOption::SkipForever; + self.skip_forever(); + } + KeyCode::Char(' ') => self.toggle_selected_item(), + KeyCode::Char('a') => self.set_all_enabled(/*enabled*/ true), + KeyCode::Char('n') => self.set_all_enabled(/*enabled*/ false), + KeyCode::Enter => self.confirm_selection(), + KeyCode::Esc => self.skip(), + _ => {} + } + } + + fn ensure_selected_item_visible(&mut self) { + let Some(selected_idx) = self.selected_item_idx else { + self.scroll_top = 0; + return; + }; + let selected_render_idx = self.selected_render_line_index(selected_idx); + let visible_rows = self.render_line_count().max(1); + if selected_render_idx < self.scroll_top { + self.scroll_top = selected_render_idx; + } else { + let bottom = self.scroll_top + visible_rows.saturating_sub(1); + if selected_render_idx > bottom { + self.scroll_top = selected_render_idx + 1 - visible_rows; + } + } + } + + fn render_line_count(&self) -> usize { + self.build_render_lines().len() + } + + fn selected_render_line_index(&self, selected_item_idx: usize) -> usize { + self.build_render_lines() + .iter() + .position(|entry| entry.item_idx == Some(selected_item_idx)) + .unwrap_or(selected_item_idx) + } + + fn section_title(cwd: Option<&std::path::Path>) -> Line<'static> { + match cwd { + Some(cwd) => Line::from(vec!["Project: ".bold(), cwd.display().to_string().dim()]), + None => Line::from("Home".bold()), + } + } + + fn build_render_lines(&self) -> Vec { + let mut lines = Vec::new(); + let mut current_scope: Option> = None; + for (idx, item) in self.items.iter().enumerate() { + let scope = item.item.cwd.as_deref(); + if current_scope != Some(scope) { + if current_scope.is_some() { + lines.push(RenderLineEntry { + item_idx: None, + kind: RenderLineKind::Section, + line: Line::from(""), + }); + } + lines.push(RenderLineEntry { + item_idx: None, + kind: RenderLineKind::Section, + line: Self::section_title(scope), + }); + current_scope = Some(scope); + } + lines.push(RenderLineEntry { + item_idx: Some(idx), + kind: RenderLineKind::Item, + line: Line::from(format!( + " [{}] {}", + if item.enabled { "x" } else { " " }, + Self::display_description(&item.item) + )), + }); + if let Some(details) = &item.item.details { + for line in Self::plugin_detail_lines(&details.plugins) { + lines.push(RenderLineEntry { + item_idx: None, + kind: RenderLineKind::ItemDetail, + line, + }); + } + } + } + lines + } + + fn render_items(&self, area: Rect, buf: &mut Buffer) { + if area.height == 0 || area.width == 0 { + return; + } + let rows = self.build_render_lines(); + let visible_rows = area.height as usize; + let mut start_idx = self.scroll_top.min(rows.len().saturating_sub(1)); + if let Some(selected_item_idx) = self.selected_item_idx { + let selected_render_idx = self.selected_render_line_index(selected_item_idx); + if selected_render_idx < start_idx { + start_idx = selected_render_idx; + } else if visible_rows > 0 { + let bottom = start_idx + visible_rows - 1; + if selected_render_idx > bottom { + start_idx = selected_render_idx + 1 - visible_rows; + } + } + } + + let mut y = area.y; + for entry in rows.iter().skip(start_idx).take(visible_rows) { + if y >= area.y + area.height { + break; + } + + let selected = + self.focus == FocusArea::Items && self.selected_item_idx == entry.item_idx; + let mut line = entry.line.clone(); + if selected { + line.spans.iter_mut().for_each(|span| { + span.style = span.style.cyan().bold(); + }); + } else if entry.kind != RenderLineKind::Item && !line.spans.is_empty() { + line.spans.iter_mut().for_each(|span| { + span.style = span.style.dim(); + }); + } + let line = truncate_line_with_ellipsis_if_overflow(line, area.width as usize); + line.render( + Rect { + x: area.x, + y, + width: area.width, + height: 1, + }, + buf, + ); + y = y.saturating_add(1); + } + } +} + +impl WidgetRef for &ExternalAgentConfigMigrationScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + + let inner_area = area.inset(Insets::vh(/*v*/ 1, /*h*/ 2)); + let error_height = u16::from(self.error.is_some()); + let fixed_height = 1u16 + 2u16 + error_height + 1u16 + 4u16 + 1u16; + let list_height = + self.render_line_count() + .max(1) + .min(inner_area.height.saturating_sub(fixed_height) as usize) as u16; + let [ + header_area, + intro_area, + error_area, + list_area, + list_gap_area, + actions_area, + footer_area, + _spacer_area, + ] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(error_height), + Constraint::Length(list_height), + Constraint::Length(1), + Constraint::Length(4), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .areas(inner_area); + + let heading = Line::from(vec!["> ".into(), "External agent config detected".bold()]); + heading.render(header_area, buf); + + Paragraph::new(vec![ + Line::from("We found settings from another agent that you can add to this project."), + Line::from("Select what to import"), + ]) + .wrap(Wrap { trim: false }) + .render(intro_area, buf); + + if let Some(error) = &self.error { + Paragraph::new(error.clone().red().to_string()) + .wrap(Wrap { trim: false }) + .render(error_area, buf); + } + + self.render_items(list_area, buf); + Clear.render(list_gap_area, buf); + + let [ + actions_intro_area, + proceed_area, + skip_area, + skip_forever_area, + ] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(actions_area); + let actions_intro = format!( + "Selected {} of {} item(s).", + self.selected_count(), + self.items.len() + ); + Paragraph::new(actions_intro) + .wrap(Wrap { trim: false }) + .render(actions_intro_area, buf); + selection_option_row_with_dim( + /*index*/ 0, + ActionMenuOption::Proceed.label().to_string(), + self.focus == FocusArea::Actions + && self.highlighted_action == ActionMenuOption::Proceed, + /*dim*/ self.focus != FocusArea::Actions || !self.proceed_enabled(), + ) + .render(proceed_area, buf); + selection_option_row_with_dim( + /*index*/ 1, + ActionMenuOption::Skip.label().to_string(), + self.focus == FocusArea::Actions && self.highlighted_action == ActionMenuOption::Skip, + /*dim*/ self.focus != FocusArea::Actions, + ) + .render(skip_area, buf); + selection_option_row_with_dim( + /*index*/ 2, + ActionMenuOption::SkipForever.label().to_string(), + self.focus == FocusArea::Actions + && self.highlighted_action == ActionMenuOption::SkipForever, + /*dim*/ self.focus != FocusArea::Actions, + ) + .render(skip_forever_area, buf); + + Line::from(vec![ + "Use ".dim(), + key_hint::plain(KeyCode::Up).into(), + "/".dim(), + key_hint::plain(KeyCode::Down).into(), + " to move, ".dim(), + key_hint::plain(KeyCode::Char(' ')).into(), + " to toggle, ".dim(), + "1".cyan(), + "/".dim(), + "2".cyan(), + "/".dim(), + "3".cyan(), + " to choose, ".dim(), + "a".cyan(), + "/".dim(), + "n".cyan(), + " for all/none".dim(), + ]) + .render(footer_area, buf); + } +} + +fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool { + key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) +} + +#[cfg(test)] +mod tests { + use super::ActionMenuOption; + use super::ExternalAgentConfigMigrationOutcome; + use super::ExternalAgentConfigMigrationScreen; + use super::FocusArea; + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + use crate::tui::FrameRequester; + use codex_app_server_protocol::ExternalAgentConfigMigrationItem; + use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; + use codex_app_server_protocol::PluginsMigration; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use insta::assert_snapshot; + use pretty_assertions::assert_eq; + use ratatui::layout::Rect; + use std::path::PathBuf; + + fn sample_items() -> Vec { + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: + "Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/config.toml" + .to_string(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: + "Migrate enabled plugins from /workspace/project/.claude/settings.json" + .to_string(), + cwd: Some(PathBuf::from("/workspace/project")), + details: Some(codex_app_server_protocol::MigrationDetails { + plugins: vec![ + PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec![ + "deployer".to_string(), + "formatter".to_string(), + "lint".to_string(), + ], + }, + PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }, + PluginsMigration { + marketplace_name: "debug".to_string(), + plugin_names: vec!["sample".to_string()], + }, + PluginsMigration { + marketplace_name: "data-tools".to_string(), + plugin_names: vec!["warehouse".to_string()], + }, + ], + }), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: "Migrate /workspace/project/CLAUDE.md to /workspace/project/AGENTS.md" + .to_string(), + cwd: Some(PathBuf::from("/workspace/project")), + details: None, + }, + ] + } + + fn render_screen( + screen: &ExternalAgentConfigMigrationScreen, + width: u16, + height: u16, + ) -> String { + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + { + let mut frame = terminal.get_frame(); + frame.render_widget_ref(screen, frame.area()); + } + terminal.flush().expect("flush"); + terminal.backend().to_string() + } + + #[test] + fn prompt_snapshot() { + let items = sample_items(); + let screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 21); + assert_snapshot!("external_agent_config_migration_prompt", rendered); + } + + #[test] + fn proceed_returns_selected_items() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(screen.is_done()); + assert_eq!( + screen.outcome(), + ExternalAgentConfigMigrationOutcome::Proceed(items) + ); + } + + #[test] + fn toggle_item_then_proceed_keeps_remaining_selection() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert!(screen.is_done()); + assert_eq!( + screen.outcome(), + ExternalAgentConfigMigrationOutcome::Proceed(vec![items[1].clone(), items[2].clone(),]) + ); + } + + #[test] + fn escape_skips_prompt() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert!(screen.is_done()); + assert_eq!(screen.outcome(), ExternalAgentConfigMigrationOutcome::Skip); + } + + #[test] + fn skip_forever_returns_skip_forever_outcome() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.move_down(); + screen.move_down(); + screen.move_down(); + screen.move_down(); + screen.move_down(); + screen.confirm_selection(); + + assert_eq!( + screen.outcome(), + ExternalAgentConfigMigrationOutcome::SkipForever + ); + } + + #[test] + fn proceed_requires_at_least_one_selected_item() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + + assert!(!screen.is_done()); + assert_eq!(screen.highlighted_action, ActionMenuOption::Proceed); + let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 20); + assert!( + rendered.contains("Select at least one item or choose a skip option."), + "expected inline validation error, got:\n{rendered}" + ); + } + + #[test] + fn proceed_action_is_skipped_when_no_items_are_selected() { + let items = sample_items(); + let mut screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + + screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + + assert_eq!(screen.focus, FocusArea::Actions); + assert_eq!(screen.highlighted_action, ActionMenuOption::Skip); + } + + #[test] + fn numeric_shortcuts_choose_actions() { + let items = sample_items(); + + let mut proceed_screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + proceed_screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)); + assert_eq!( + proceed_screen.outcome(), + ExternalAgentConfigMigrationOutcome::Proceed(items.clone()) + ); + + let mut skip_screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + skip_screen.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + assert_eq!( + skip_screen.outcome(), + ExternalAgentConfigMigrationOutcome::Skip + ); + + let mut skip_forever_screen = ExternalAgentConfigMigrationScreen::new( + FrameRequester::test_dummy(), + &items, + &items, + /*error*/ None, + ); + skip_forever_screen.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE)); + assert_eq!( + skip_forever_screen.outcome(), + ExternalAgentConfigMigrationOutcome::SkipForever + ); + } +} diff --git a/codex-rs/tui/src/external_agent_config_migration_startup.rs b/codex-rs/tui/src/external_agent_config_migration_startup.rs new file mode 100644 index 000000000..93f230253 --- /dev/null +++ b/codex-rs/tui/src/external_agent_config_migration_startup.rs @@ -0,0 +1,567 @@ +use crate::app_server_session::AppServerSession; +use crate::external_agent_config_migration::ExternalAgentConfigMigrationOutcome; +use crate::external_agent_config_migration::run_external_agent_config_migration_prompt; +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; +use codex_features::Feature; +use color_eyre::eyre::Result; +use color_eyre::eyre::WrapErr; +use std::collections::BTreeSet; +use std::path::Path; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; +use toml::Value as TomlValue; + +const EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS: i64 = 5 * 24 * 60 * 60; + +pub(crate) enum ExternalAgentConfigMigrationStartupOutcome { + Continue { success_message: Option }, + ExitRequested, +} + +fn should_show_external_agent_config_migration_prompt( + config: &Config, + entered_trust_nux: bool, +) -> bool { + entered_trust_nux && config.features.enabled(Feature::ExternalMigration) +} + +fn external_config_migration_project_key(path: &Path) -> String { + path.display().to_string() +} + +fn is_external_config_migration_scope_hidden(config: &Config, cwd: Option<&Path>) -> bool { + match cwd { + Some(cwd) => config + .notices + .external_config_migration_prompts + .projects + .get(&external_config_migration_project_key(cwd)) + .copied() + .unwrap_or(false), + None => config + .notices + .external_config_migration_prompts + .home + .unwrap_or(false), + } +} + +fn external_config_migration_last_prompted_at(config: &Config, cwd: Option<&Path>) -> Option { + match cwd { + Some(cwd) => config + .notices + .external_config_migration_prompts + .project_last_prompted_at + .get(&external_config_migration_project_key(cwd)) + .copied(), + None => { + config + .notices + .external_config_migration_prompts + .home_last_prompted_at + } + } +} + +fn is_external_config_migration_scope_cooling_down( + config: &Config, + cwd: Option<&Path>, + now_unix_seconds: i64, +) -> bool { + external_config_migration_last_prompted_at(config, cwd).is_some_and(|last_prompted_at| { + last_prompted_at.saturating_add(EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS) + > now_unix_seconds + }) +} + +fn visible_external_agent_config_migration_items( + config: &Config, + items: Vec, + now_unix_seconds: i64, +) -> Vec { + items + .into_iter() + .filter(|item| { + !is_external_config_migration_scope_hidden(config, item.cwd.as_deref()) + && !is_external_config_migration_scope_cooling_down( + config, + item.cwd.as_deref(), + now_unix_seconds, + ) + }) + .collect() +} + +fn external_agent_config_migration_success_message( + items: &[ExternalAgentConfigMigrationItem], +) -> String { + if items.iter().any(|item| { + item.item_type == codex_app_server_protocol::ExternalAgentConfigMigrationItemType::Plugins + }) { + "External config migration completed. Plugin migration is still in progress and may take a few minutes." + .to_string() + } else { + "External config migration completed successfully.".to_string() + } +} + +fn unix_seconds_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +async fn persist_external_agent_config_migration_prompt_shown( + config: &mut Config, + items: &[ExternalAgentConfigMigrationItem], + now_unix_seconds: i64, +) -> Result<()> { + let mut edits = Vec::new(); + if items.iter().any(|item| item.cwd.is_none()) { + edits.push( + ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(now_unix_seconds), + ); + } + + for project in items + .iter() + .filter_map(|item| item.cwd.as_deref()) + .map(external_config_migration_project_key) + { + edits.push( + ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( + project, + now_unix_seconds, + ), + ); + } + + if edits.is_empty() { + return Ok(()); + } + + ConfigEditsBuilder::new(&config.codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|err| color_eyre::eyre::eyre!("{err}")) + .wrap_err("Failed to save external config migration prompt timestamp")?; + + if items.iter().any(|item| item.cwd.is_none()) { + config + .notices + .external_config_migration_prompts + .home_last_prompted_at = Some(now_unix_seconds); + } + for project in items + .iter() + .filter_map(|item| item.cwd.as_deref()) + .map(external_config_migration_project_key) + { + config + .notices + .external_config_migration_prompts + .project_last_prompted_at + .insert(project, now_unix_seconds); + } + + Ok(()) +} + +async fn persist_external_agent_config_migration_prompt_dismissal( + config: &mut Config, + items: &[ExternalAgentConfigMigrationItem], +) -> Result<()> { + let hide_home = items.iter().any(|item| item.cwd.is_none()); + let projects = items + .iter() + .filter_map(|item| item.cwd.as_deref()) + .map(external_config_migration_project_key) + .collect::>(); + + let mut edits = Vec::new(); + if hide_home + && !config + .notices + .external_config_migration_prompts + .home + .unwrap_or(false) + { + edits.push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( + true, + )); + } + for project in &projects { + if !config + .notices + .external_config_migration_prompts + .projects + .get(project) + .copied() + .unwrap_or(false) + { + edits.push( + ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( + project.clone(), + true, + ), + ); + } + } + + if edits.is_empty() { + return Ok(()); + } + + ConfigEditsBuilder::new(&config.codex_home) + .with_edits(edits) + .apply() + .await + .map_err(|err| color_eyre::eyre::eyre!("{err}")) + .wrap_err("Failed to save external config migration prompt preference")?; + + if hide_home { + config.notices.external_config_migration_prompts.home = Some(true); + } + for project in projects { + config + .notices + .external_config_migration_prompts + .projects + .insert(project, true); + } + + Ok(()) +} + +pub(crate) async fn handle_external_agent_config_migration_prompt_if_needed( + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + config: &mut Config, + cli_kv_overrides: &[(String, TomlValue)], + harness_overrides: &ConfigOverrides, + entered_trust_nux: bool, +) -> Result { + if !should_show_external_agent_config_migration_prompt(config, entered_trust_nux) { + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + + let now_unix_seconds = unix_seconds_now(); + let detected_items = match app_server + .external_agent_config_detect(ExternalAgentConfigDetectParams { + include_home: true, + cwds: Some(vec![config.cwd.to_path_buf()]), + }) + .await + { + Ok(response) => { + visible_external_agent_config_migration_items(config, response.items, now_unix_seconds) + } + Err(err) => { + tracing::warn!( + error = %err, + cwd = %config.cwd.display(), + "failed to detect external agent config migrations; continuing startup" + ); + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + }; + + if detected_items.is_empty() { + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + + if let Err(err) = persist_external_agent_config_migration_prompt_shown( + config, + &detected_items, + now_unix_seconds, + ) + .await + { + tracing::warn!( + error = %err, + cwd = %config.cwd.display(), + "failed to persist external config migration prompt timestamp" + ); + } + + let mut selected_items = detected_items.clone(); + let mut error: Option = None; + + loop { + match run_external_agent_config_migration_prompt( + tui, + &detected_items, + &selected_items, + error.as_deref(), + ) + .await + { + ExternalAgentConfigMigrationOutcome::Proceed(items) => { + selected_items = items.clone(); + match app_server.external_agent_config_import(items).await { + Ok(_) => { + let success_message = + external_agent_config_migration_success_message(&selected_items); + *config = ConfigBuilder::default() + .codex_home(config.codex_home.to_path_buf()) + .cli_overrides(cli_kv_overrides.to_vec()) + .harness_overrides(harness_overrides.clone()) + .build() + .await + .wrap_err("Failed to reload config after external agent migration")?; + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: Some(success_message), + }); + } + Err(err) => { + tracing::warn!( + error = %err, + cwd = %config.cwd.display(), + "failed to import external agent config migration items" + ); + error = Some(format!("Migration failed: {err}")); + } + } + } + ExternalAgentConfigMigrationOutcome::Skip => { + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + ExternalAgentConfigMigrationOutcome::SkipForever => { + match persist_external_agent_config_migration_prompt_dismissal( + config, + &detected_items, + ) + .await + { + Ok(()) => { + return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue { + success_message: None, + }); + } + Err(err) => { + tracing::warn!( + error = %err, + cwd = %config.cwd.display(), + "failed to persist external config migration prompt dismissal" + ); + error = Some(format!("Failed to save preference: {err}")); + } + } + } + ExternalAgentConfigMigrationOutcome::Exit => { + return Ok(ExternalAgentConfigMigrationStartupOutcome::ExitRequested); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + use tempfile::tempdir; + + #[tokio::test] + async fn visible_external_agent_config_migration_items_omits_hidden_scopes() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config.notices.external_config_migration_prompts.home = Some(true); + config + .notices + .external_config_migration_prompts + .projects + .insert("/tmp/project".to_string(), true); + + let visible = visible_external_agent_config_migration_items( + &config, + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: "home".to_string(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: "project".to_string(), + cwd: Some(PathBuf::from("/tmp/project")), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "other project".to_string(), + cwd: Some(PathBuf::from("/tmp/other")), + details: None, + }, + ], + /*now_unix_seconds*/ 1_760_000_000, + ); + + assert_eq!( + visible, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "other project".to_string(), + cwd: Some(PathBuf::from("/tmp/other")), + details: None, + }] + ); + } + + #[tokio::test] + async fn visible_external_agent_config_migration_items_omits_recently_prompted_scopes() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config + .notices + .external_config_migration_prompts + .home_last_prompted_at = Some(1_760_000_000); + config + .notices + .external_config_migration_prompts + .project_last_prompted_at + .insert("/tmp/project".to_string(), 1_760_000_000); + + let visible = visible_external_agent_config_migration_items( + &config, + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: "home".to_string(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: "project".to_string(), + cwd: Some(PathBuf::from("/tmp/project")), + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "other project".to_string(), + cwd: Some(PathBuf::from("/tmp/other")), + details: None, + }, + ], + /*now_unix_seconds*/ + 1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS - 1, + ); + + assert_eq!( + visible, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "other project".to_string(), + cwd: Some(PathBuf::from("/tmp/other")), + details: None, + }] + ); + } + + #[tokio::test] + async fn external_config_migration_scope_cooldown_expires_after_five_days() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + config + .notices + .external_config_migration_prompts + .home_last_prompted_at = Some(1_760_000_000); + + assert!(is_external_config_migration_scope_cooling_down( + &config, + /*cwd*/ None, + 1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS - 1, + )); + assert!(!is_external_config_migration_scope_cooling_down( + &config, + /*cwd*/ None, + 1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS, + )); + } + + #[test] + fn external_agent_config_migration_success_message_mentions_plugins_when_present() { + let message = external_agent_config_migration_success_message(&[ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: String::new(), + cwd: None, + details: None, + }, + ]); + + assert_eq!( + message, + "External config migration completed. Plugin migration is still in progress and may take a few minutes." + ); + } + + #[test] + fn external_agent_config_migration_success_message_omits_plugins_copy_when_absent() { + let message = + external_agent_config_migration_success_message(&[ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: None, + details: None, + }]); + + assert_eq!(message, "External config migration completed successfully."); + } + + #[tokio::test] + async fn external_agent_config_migration_prompt_requires_trust_nux_entry() { + let codex_home = tempdir().expect("temp codex home"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config"); + let _ = config.features.enable(Feature::ExternalMigration); + + assert!(!should_show_external_agent_config_migration_prompt( + &config, /*entered_trust_nux*/ false, + )); + assert!(should_show_external_agent_config_migration_prompt( + &config, /*entered_trust_nux*/ true, + )); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b67dd419f..c48e49b56 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -118,6 +118,8 @@ mod debug_config; mod diff_render; mod exec_cell; mod exec_command; +mod external_agent_config_migration; +mod external_agent_config_migration_startup; mod external_editor; mod file_search; mod frames; @@ -1452,6 +1454,7 @@ async fn run_ratatui_app( session_selection, feedback, should_show_trust_screen, // Proxy to: is it a first run in this directory? + should_show_trust_screen_flag, // Preserve the startup-time trust NUX signal before onboarding should_prompt_windows_sandbox_nux_at_startup, remote_url, remote_auth_token, diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap new file mode 100644 index 000000000..3f518141c --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/external_agent_config_migration.rs +assertion_line: 824 +expression: rendered +--- + + > External agent config detected + We found settings from another agent that you can add to this project. + Select what to import + Home + [x] Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/con… + + Project: /workspace/project + [x] Migrate enabled plugins from /workspace/project/.claude/settings.json… + • acme-tools: deployer, formatter, +1 more + • team-marketplace: asana + • debug: sample + • +1 more marketplaces + [x] Migrate /workspace/project/CLAUDE.md to /workspace/project/AGENTS.md + + Selected 3 of 3 item(s). + 1. Proceed with selected + 2. Skip for now + 3. Don't ask again + Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none