diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs index 1699239ab4..b39fba7c87 100644 --- a/codex-rs/core/src/external_agent_config.rs +++ b/codex-rs/core/src/external_agent_config.rs @@ -138,22 +138,11 @@ impl ExternalAgentConfigService { let cwd = migration_item.cwd; let details = migration_item.details; tokio::spawn(async move { - match service.import_plugins(cwd.as_deref(), details).await { - Ok(outcome) => { - tracing::info!( - succeeded_marketplaces = outcome.succeeded_marketplaces.len(), - succeeded_plugin_ids = outcome.succeeded_plugin_ids.len(), - failed_marketplaces = outcome.failed_marketplaces.len(), - failed_plugin_ids = outcome.failed_plugin_ids.len(), - "external agent config plugin import completed" - ); - } - Err(err) => { - tracing::warn!( - error = %err, - "external agent config plugin import failed" - ); - } + if let Err(err) = service.import_plugins(cwd.as_deref(), details).await { + tracing::warn!( + error = %err, + "external agent config plugin import failed" + ); } }); emit_migration_metric( @@ -174,6 +163,8 @@ impl ExternalAgentConfigService { repo_root: Option<&Path>, items: &mut Vec, ) -> io::Result<()> { + let configured_enabled_plugins = + configured_enabled_plugin_ids(&self.codex_home, repo_root)?; let cwd = repo_root.map(Path::to_path_buf); let source_settings = repo_root.map_or_else( || self.external_agent_home.join("settings.json"), @@ -224,6 +215,7 @@ impl ExternalAgentConfigService { source_settings.as_path(), cwd.clone(), settings.as_ref(), + &configured_enabled_plugins, items, ); @@ -299,9 +291,12 @@ impl ExternalAgentConfigService { source_settings: &Path, cwd: Option, settings: Option<&JsonValue>, + configured_enabled_plugins: &HashSet, items: &mut Vec, ) { - let Some(plugin_details) = settings.and_then(extract_plugin_migration_details) else { + let Some(plugin_details) = settings.and_then(|settings| { + extract_plugin_migration_details(settings, configured_enabled_plugins) + }) else { return; }; @@ -537,9 +532,15 @@ fn read_external_settings(path: &Path) -> io::Result> { Ok(Some(settings)) } -fn extract_plugin_migration_details(settings: &JsonValue) -> Option { +fn extract_plugin_migration_details( + settings: &JsonValue, + configured_enabled_plugins: &HashSet, +) -> Option { let mut plugins = BTreeMap::new(); - for plugin_id in collect_enabled_plugins(settings) { + for plugin_id in collect_enabled_plugins(settings) + .into_iter() + .filter(|plugin_id| !configured_enabled_plugins.contains(plugin_id)) + { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { continue; }; @@ -591,6 +592,49 @@ fn collect_enabled_plugins(settings: &JsonValue) -> Vec { .collect() } +fn configured_enabled_plugin_ids( + codex_home: &Path, + repo_root: Option<&Path>, +) -> io::Result> { + let mut plugin_states = BTreeMap::new(); + for config_path in [ + Some(codex_home.join("config.toml")), + repo_root.map(|repo_root| repo_root.join(".codex").join("config.toml")), + ] + .into_iter() + .flatten() + { + if !config_path.is_file() { + continue; + } + + let raw_config = fs::read_to_string(&config_path)?; + if raw_config.trim().is_empty() { + continue; + } + + let config = toml::from_str::(&raw_config) + .map_err(|err| invalid_data_error(format!("invalid config.toml: {err}")))?; + let Some(plugins) = config.get("plugins").and_then(TomlValue::as_table) else { + continue; + }; + + for (plugin_id, enabled) in plugins.iter().filter_map(|(plugin_id, plugin_config)| { + plugin_config + .get("enabled") + .and_then(TomlValue::as_bool) + .map(|enabled| (plugin_id, enabled)) + }) { + plugin_states.insert(plugin_id.clone(), enabled); + } + } + + Ok(plugin_states + .into_iter() + .filter_map(|(plugin_id, enabled)| enabled.then_some(plugin_id)) + .collect()) +} + fn collect_marketplace_import_sources( settings: &JsonValue, ) -> BTreeMap { diff --git a/codex-rs/core/src/external_agent_config_tests.rs b/codex-rs/core/src/external_agent_config_tests.rs index f2afab0170..c343688560 100644 --- a/codex-rs/core/src/external_agent_config_tests.rs +++ b/codex-rs/core/src/external_agent_config_tests.rs @@ -449,6 +449,60 @@ fn detect_home_lists_enabled_plugins_from_settings() { ); } +#[test] +fn detect_repo_skips_plugins_that_are_already_enabled_in_codex() { + let root = TempDir::new().expect("create tempdir"); + let claude_home = root.path().join(".claude"); + let codex_home = root.path().join(".codex"); + let repo_root = root.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).expect("create git dir"); + fs::create_dir_all(repo_root.join(".claude")).expect("create repo claude dir"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::write( + repo_root.join(".claude").join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true, + "deployer@acme-tools": true + } + }"#, + ) + .expect("write repo settings"); + fs::write( + codex_home.join("config.toml"), + r#" +[plugins."formatter@acme-tools"] +enabled = true +"#, + ) + .expect("write codex config"); + + let items = service_for_paths(claude_home, codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: false, + cwds: Some(vec![repo_root.clone()]), + }) + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Import enabled plugins from {}", + repo_root.join(".claude").join("settings.json").display() + ), + cwd: Some(repo_root), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["deployer".to_string()], + }], + }), + }] + ); +} + #[tokio::test] async fn import_plugins_requires_details() { let (_root, claude_home, codex_home) = fixture_paths(); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 6a919485c0..df4a1f5f80 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -150,6 +150,8 @@ pub enum Feature { ToolSuggest, /// Enable plugins. Plugins, + /// Show the startup prompt for migrating external agent config into Codex. + ExternalMigrate, /// Allow the model to invoke the built-in image generation tool. ImageGeneration, /// Allow prompting and installing missing MCP dependencies. @@ -795,6 +797,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ExternalMigrate, + key: "external_migrate", + stage: Stage::Experimental { + name: "External migrate", + 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 367527ba15..eb9afbfe7c 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_migrate_is_experimental_and_disabled_by_default() { + let spec = Feature::ExternalMigrate.info(); + let stage = spec.stage; + + assert!(matches!(stage, Stage::Experimental { .. })); + assert_eq!(stage.experimental_menu_name(), Some("External migrate")); + 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::ExternalMigrate.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 0c2f5559e9..e003866cf3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -893,6 +893,24 @@ fn visible_external_agent_config_migration_items( .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() + } +} + +struct ExternalAgentConfigMigrationPromptResult { + exit_info: Option, + success_message: Option, +} + async fn persist_external_agent_config_migration_prompt_dismissal( config: &mut Config, items: &[ExternalAgentConfigMigrationItem], @@ -965,7 +983,14 @@ async fn handle_external_agent_config_migration_prompt_if_needed( config: &mut Config, cli_kv_overrides: &[(String, TomlValue)], harness_overrides: &ConfigOverrides, -) -> Result> { +) -> Result { + if !config.features.enabled(Feature::ExternalMigrate) { + return Ok(ExternalAgentConfigMigrationPromptResult { + exit_info: None, + success_message: None, + }); + } + let detected_items = match app_server .external_agent_config_detect(ExternalAgentConfigDetectParams { include_home: true, @@ -980,12 +1005,18 @@ async fn handle_external_agent_config_migration_prompt_if_needed( cwd = %config.cwd.display(), "failed to detect external agent config migrations; continuing startup" ); - return Ok(None); + return Ok(ExternalAgentConfigMigrationPromptResult { + exit_info: None, + success_message: None, + }); } }; if detected_items.is_empty() { - return Ok(None); + return Ok(ExternalAgentConfigMigrationPromptResult { + exit_info: None, + success_message: None, + }); } let mut selected_items = detected_items.clone(); @@ -1004,10 +1035,15 @@ async fn handle_external_agent_config_migration_prompt_if_needed( 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 = rebuild_startup_config(config, cli_kv_overrides, harness_overrides) .await?; - return Ok(None); + return Ok(ExternalAgentConfigMigrationPromptResult { + exit_info: None, + success_message: Some(success_message), + }); } Err(err) => { tracing::warn!( @@ -1019,7 +1055,12 @@ async fn handle_external_agent_config_migration_prompt_if_needed( } } } - ExternalAgentConfigMigrationOutcome::Skip => return Ok(None), + ExternalAgentConfigMigrationOutcome::Skip => { + return Ok(ExternalAgentConfigMigrationPromptResult { + exit_info: None, + success_message: None, + }); + } ExternalAgentConfigMigrationOutcome::SkipForever => { match persist_external_agent_config_migration_prompt_dismissal( config, @@ -1027,7 +1068,12 @@ async fn handle_external_agent_config_migration_prompt_if_needed( ) .await { - Ok(()) => return Ok(None), + Ok(()) => { + return Ok(ExternalAgentConfigMigrationPromptResult { + exit_info: None, + success_message: None, + }); + } Err(err) => { tracing::warn!( error = %err, @@ -1039,13 +1085,16 @@ async fn handle_external_agent_config_migration_prompt_if_needed( } } ExternalAgentConfigMigrationOutcome::Exit => { - return Ok(Some(AppExitInfo { - token_usage: TokenUsage::default(), - thread_id: None, - thread_name: None, - update_action: None, - exit_reason: ExitReason::UserRequested, - })); + return Ok(ExternalAgentConfigMigrationPromptResult { + exit_info: Some(AppExitInfo { + token_usage: TokenUsage::default(), + thread_id: None, + thread_name: None, + update_action: None, + exit_reason: ExitReason::UserRequested, + }), + success_message: None, + }); } } } @@ -3855,15 +3904,19 @@ impl App { let harness_overrides = normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; - let exit_info = handle_external_agent_config_migration_prompt_if_needed( - tui, - &mut app_server, - &mut config, - &cli_kv_overrides, - &harness_overrides, - ) - .await?; - if let Some(exit_info) = exit_info { + let external_agent_config_migration_result = + handle_external_agent_config_migration_prompt_if_needed( + tui, + &mut app_server, + &mut config, + &cli_kv_overrides, + &harness_overrides, + ) + .await?; + let external_agent_config_migration_message = external_agent_config_migration_result + .success_message + .clone(); + if let Some(exit_info) = external_agent_config_migration_result.exit_info { app_server .shutdown() .await @@ -4043,6 +4096,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); @@ -10376,18 +10432,21 @@ guardian_approval = true codex_app_server_protocol::ExternalAgentConfigMigrationItemType::Config, description: "home".to_string(), cwd: None, + details: None, }, ExternalAgentConfigMigrationItem { item_type: codex_app_server_protocol::ExternalAgentConfigMigrationItemType::AgentsMd, description: "project".to_string(), cwd: Some(PathBuf::from("/tmp/project")), + details: None, }, ExternalAgentConfigMigrationItem { item_type: codex_app_server_protocol::ExternalAgentConfigMigrationItemType::Skills, description: "other project".to_string(), cwd: Some(PathBuf::from("/tmp/other")), + details: None, }, ], ); @@ -10398,10 +10457,48 @@ guardian_approval = true item_type: codex_app_server_protocol::ExternalAgentConfigMigrationItemType::Skills, description: "other project".to_string(), cwd: Some(PathBuf::from("/tmp/other")), + details: None, }] ); } + #[test] + fn external_agent_config_migration_success_message_mentions_plugins_when_present() { + let message = external_agent_config_migration_success_message(&[ + ExternalAgentConfigMigrationItem { + item_type: codex_app_server_protocol::ExternalAgentConfigMigrationItemType::Config, + description: String::new(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: codex_app_server_protocol::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: + codex_app_server_protocol::ExternalAgentConfigMigrationItemType::AgentsMd, + description: String::new(), + cwd: None, + details: None, + }]); + + assert_eq!(message, "External config migration completed successfully."); + } + #[tokio::test] async fn model_migration_prompt_respects_hide_flag_and_self_target() { let mut seen = BTreeMap::new(); diff --git a/codex-rs/tui/src/external_agent_config_migration.rs b/codex-rs/tui/src/external_agent_config_migration.rs index c8151a7aad..f246b6b649 100644 --- a/codex-rs/tui/src/external_agent_config_migration.rs +++ b/codex-rs/tui/src/external_agent_config_migration.rs @@ -1,3 +1,4 @@ +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; @@ -7,6 +8,7 @@ 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; @@ -79,9 +81,17 @@ struct MigrationSelection { 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], @@ -136,6 +146,78 @@ struct ExternalAgentConfigMigrationScreen { } impl ExternalAgentConfigMigrationScreen { + 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, "Copy skill folders from ", " 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("Import enabled plugins from ") + { + let description = format!( + "Import 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], @@ -164,6 +246,40 @@ impl ExternalAgentConfigMigrationScreen { } } + 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 } @@ -317,6 +433,21 @@ impl ExternalAgentConfigMigrationScreen { 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), @@ -370,23 +501,35 @@ impl ExternalAgentConfigMigrationScreen { 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 { " " }, - item.item.description + 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 } @@ -423,7 +566,7 @@ impl ExternalAgentConfigMigrationScreen { line.spans.iter_mut().for_each(|span| { span.style = span.style.cyan().bold(); }); - } else if entry.item_idx.is_none() && !line.spans.is_empty() { + } else if entry.kind != RenderLineKind::Item && !line.spans.is_empty() { line.spans.iter_mut().for_each(|span| { span.style = span.style.dim(); }); @@ -549,6 +692,12 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen { " 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(), @@ -590,6 +739,7 @@ mod tests { 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; @@ -606,12 +756,44 @@ mod tests { "Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/config.toml" .to_string(), cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Import 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: "Import /workspace/project/CLAUDE.md to /workspace/project/AGENTS.md" .to_string(), cwd: Some(PathBuf::from("/workspace/project")), + details: None, }, ] } @@ -750,4 +932,45 @@ mod tests { "expected inline validation error, got:\n{rendered}" ); } + + #[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/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 index 24b8165b15..1ca1e3ca50 100644 --- 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 @@ -1,5 +1,6 @@ --- source: tui/src/external_agent_config_migration.rs +assertion_line: 757 expression: rendered --- @@ -10,10 +11,15 @@ expression: rendered [x] Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/con… Project: /workspace/project - [x] Import /workspace/project/CLAUDE.md to /workspace/project/AGENTS.md + [x] Import enabled plugins from .claude/settings.json (4 marketplaces, 6 p… + • acme-tools: deployer, formatter, +1 more + • team-marketplace: asana + • debug: sample + • +1 more marketplaces + [x] Import CLAUDE.md to AGENTS.md - Selected 2 of 2 item(s). + Selected 3 of 3 item(s). 1. Proceed with selected 2. Skip for now 3. Don't ask again for these locations - Use ↑/↓ to move, space to toggle, a/n for all/none + Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap.new b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap.new new file mode 100644 index 0000000000..337c8f4e4d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt.snap.new @@ -0,0 +1,23 @@ +--- +source: tui/src/external_agent_config_migration.rs +assertion_line: 773 +expression: rendered +--- + + > Migratable 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] Import enabled plugins from .claude/settings.json + • acme-tools: deployer, formatter + • team-marketplace: asana + [x] Import CLAUDE.md to AGENTS.md + + Selected 3 of 3 item(s). + 1. Proceed with selected + 2. Skip for now + 3. Don't ask again for these locations + Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap.new b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap.new new file mode 100644 index 0000000000..d473d45814 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_lists_tools_for_hyphenated_server_names.snap.new @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 3367 +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • some-server (disabled) + • Reason: requirements (/etc/codex/requirements.toml) diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap.new b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap.new new file mode 100644 index 0000000000..2dbc3f4d4f --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap.new @@ -0,0 +1,14 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 3327 +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • docs (disabled) + • Reason: requirements (/etc/codex/requirements.toml) + + • http (disabled) + • Reason: requirements (/etc/codex/requirements.toml)