mirror of
https://github.com/openai/codex.git
synced 2026-06-02 11:22:01 +00:00
[TUI] add external config migration prompt when start TUI
This commit is contained in:
@@ -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<ExternalAgentConfigMigrationItem>,
|
||||
) -> 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<PathBuf>,
|
||||
settings: Option<&JsonValue>,
|
||||
configured_enabled_plugins: &HashSet<String>,
|
||||
items: &mut Vec<ExternalAgentConfigMigrationItem>,
|
||||
) {
|
||||
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<Option<JsonValue>> {
|
||||
Ok(Some(settings))
|
||||
}
|
||||
|
||||
fn extract_plugin_migration_details(settings: &JsonValue) -> Option<MigrationDetails> {
|
||||
fn extract_plugin_migration_details(
|
||||
settings: &JsonValue,
|
||||
configured_enabled_plugins: &HashSet<String>,
|
||||
) -> Option<MigrationDetails> {
|
||||
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<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn configured_enabled_plugin_ids(
|
||||
codex_home: &Path,
|
||||
repo_root: Option<&Path>,
|
||||
) -> io::Result<HashSet<String>> {
|
||||
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::<TomlValue>(&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<String, MarketplaceImportSource> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<AppExitInfo>,
|
||||
success_message: Option<String>,
|
||||
}
|
||||
|
||||
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<Option<AppExitInfo>> {
|
||||
) -> Result<ExternalAgentConfigMigrationPromptResult> {
|
||||
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();
|
||||
|
||||
@@ -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<usize>,
|
||||
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<String> {
|
||||
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::<usize>();
|
||||
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<Line<'static>> {
|
||||
let mut lines = plugin_groups
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|plugin_group| {
|
||||
let mut plugin_names = plugin_group
|
||||
.plugin_names
|
||||
.iter()
|
||||
.take(2)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user