diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 214f136d7..68c242504 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1723,6 +1723,13 @@ }, "type": "array" }, + "skills": { + "default": [], + "items": { + "$ref": "#/definitions/SkillMigration" + }, + "type": "array" + }, "subagents": { "default": [], "items": { @@ -3407,6 +3414,17 @@ ], "type": "object" }, + "SkillMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "SkillsConfigWriteParams": { "properties": { "enabled": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 06c826c15..4bbf58a12 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12392,6 +12392,13 @@ }, "type": "array" }, + "skills": { + "default": [], + "items": { + "$ref": "#/definitions/v2/SkillMigration" + }, + "type": "array" + }, "subagents": { "default": [], "items": { @@ -16338,6 +16345,17 @@ ], "type": "object" }, + "SkillMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "SkillScope": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b5d882644..9cae10233 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8796,6 +8796,13 @@ }, "type": "array" }, + "skills": { + "default": [], + "items": { + "$ref": "#/definitions/SkillMigration" + }, + "type": "array" + }, "subagents": { "default": [], "items": { @@ -14117,6 +14124,17 @@ ], "type": "object" }, + "SkillMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "SkillScope": { "enum": [ "user", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json index b61b7064a..0043f0473 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectResponse.json @@ -117,6 +117,13 @@ }, "type": "array" }, + "skills": { + "default": [], + "items": { + "$ref": "#/definitions/SkillMigration" + }, + "type": "array" + }, "subagents": { "default": [], "items": { @@ -166,6 +173,17 @@ ], "type": "object" }, + "SkillMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "SubagentMigration": { "properties": { "name": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index bc432b873..d02241f95 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -117,6 +117,13 @@ }, "type": "array" }, + "skills": { + "default": [], + "items": { + "$ref": "#/definitions/SkillMigration" + }, + "type": "array" + }, "subagents": { "default": [], "items": { @@ -166,6 +173,17 @@ ], "type": "object" }, + "SkillMigration": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "SubagentMigration": { "properties": { "name": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts index 4fe87eabd..21f779847 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MigrationDetails.ts @@ -6,6 +6,7 @@ import type { HookMigration } from "./HookMigration"; import type { McpServerMigration } from "./McpServerMigration"; import type { PluginsMigration } from "./PluginsMigration"; import type { SessionMigration } from "./SessionMigration"; +import type { SkillMigration } from "./SkillMigration"; import type { SubagentMigration } from "./SubagentMigration"; -export type MigrationDetails = { plugins: Array, sessions: Array, mcpServers: Array, hooks: Array, subagents: Array, commands: Array, }; +export type MigrationDetails = { plugins: Array, skills: Array, sessions: Array, mcpServers: Array, hooks: Array, subagents: Array, commands: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillMigration.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillMigration.ts new file mode 100644 index 000000000..0555ffc88 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillMigration.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SkillMigration = { name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 57c08391c..ff7df9e9e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -363,6 +363,7 @@ export type { SkillDependencies } from "./SkillDependencies"; export type { SkillErrorInfo } from "./SkillErrorInfo"; export type { SkillInterface } from "./SkillInterface"; export type { SkillMetadata } from "./SkillMetadata"; +export type { SkillMigration } from "./SkillMigration"; export type { SkillScope } from "./SkillScope"; export type { SkillSummary } from "./SkillSummary"; export type { SkillToolDependency } from "./SkillToolDependency"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index b37f10dce..4c9e355b4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -574,6 +574,13 @@ pub struct PluginsMigration { pub plugin_names: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillMigration { + pub name: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -618,6 +625,8 @@ pub struct MigrationDetails { #[serde(default)] pub plugins: Vec, #[serde(default)] + pub skills: Vec, + #[serde(default)] pub sessions: Vec, #[serde(default)] pub mcp_servers: Vec, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 88d676b5f..c7ec2c15c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -238,8 +238,8 @@ Example with notification opt-out: - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. -- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. -- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. +- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and multi-item migrations may additionally include structured `details` with plugin ids, skill names, session metadata, or other artifact names. +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 8c8c67d53..ca1023e52 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -72,6 +72,7 @@ pub(crate) struct NamedMigration { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct MigrationDetails { pub plugins: Vec, + pub skills: Vec, pub sessions: Vec, pub mcp_servers: Vec, pub hooks: Vec, @@ -570,7 +571,8 @@ impl ExternalAgentConfigService { || self.home_target_skills_dir(), |repo_root| repo_root.join(".agents").join("skills"), ); - let skills_count = count_missing_subdirectories(&source_skills, &target_skills)?; + let skill_names = missing_subdirectory_names(&source_skills, &target_skills)?; + let skills_count = skill_names.len(); if skills_count > 0 { items.push(ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::Skills, @@ -580,7 +582,10 @@ impl ExternalAgentConfigService { target_skills.display() ), cwd: cwd.clone(), - details: None, + details: Some(MigrationDetails { + skills: named_migrations(skill_names), + ..Default::default() + }), }); emit_migration_metric( EXTERNAL_AGENT_CONFIG_DETECT_METRIC, @@ -1569,13 +1574,16 @@ fn collect_subdirectory_names(path: &Path) -> io::Result> { Ok(names) } -fn count_missing_subdirectories(source: &Path, target: &Path) -> io::Result { +fn missing_subdirectory_names(source: &Path, target: &Path) -> io::Result> { let source_names = collect_subdirectory_names(source)?; let target_names = collect_subdirectory_names(target)?; - Ok(source_names - .iter() - .filter(|name| !target_names.contains(*name)) - .count()) + let mut missing_names = source_names + .into_iter() + .filter(|name| !target_names.contains(name)) + .map(|name| name.to_string_lossy().into_owned()) + .collect::>(); + missing_names.sort(); + Ok(missing_names) } fn is_missing_or_empty_text_file(path: &Path) -> io::Result { diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 447741d14..9ab67a137 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -113,7 +113,10 @@ async fn detect_home_lists_config_skills_and_agents_md() { agents_skills.display() ), cwd: None, - details: None, + details: Some(MigrationDetails { + skills: named_migrations(vec!["skill-a".to_string()]), + ..Default::default() + }), }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, @@ -305,7 +308,10 @@ async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid( repo_root.join(".agents").join("skills").display() ), cwd: Some(repo_root.clone()), - details: None, + details: Some(MigrationDetails { + skills: named_migrations(vec!["skill-a".to_string()]), + ..Default::default() + }), }, ExternalAgentConfigMigrationItem { item_type: ExternalAgentConfigMigrationItemType::AgentsMd, diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 69bb4218c..5bae994ca 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -38,6 +38,7 @@ use codex_app_server_protocol::McpServerMigration; use codex_app_server_protocol::MigrationDetails; use codex_app_server_protocol::PluginsMigration; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SkillMigration; use codex_arg0::Arg0DispatchPaths; use codex_core::ThreadManager; use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration; @@ -161,6 +162,11 @@ impl ExternalAgentConfigRequestProcessor { plugin_names: plugin.plugin_names, }) .collect(), + skills: details + .skills + .into_iter() + .map(|skill| SkillMigration { name: skill.name }) + .collect(), sessions: details .sessions .into_iter() @@ -490,6 +496,11 @@ impl ExternalAgentConfigRequestProcessor { } }) .collect(), + skills: details + .skills + .into_iter() + .map(|skill| CoreNamedMigration { name: skill.name }) + .collect(), sessions: details .sessions .into_iter() diff --git a/codex-rs/tui/src/app/app_server_events.rs b/codex-rs/tui/src/app/app_server_events.rs index 4bd794a55..06ed4320d 100644 --- a/codex-rs/tui/src/app/app_server_events.rs +++ b/codex-rs/tui/src/app/app_server_events.rs @@ -103,7 +103,7 @@ impl App { ); return; } - ServerNotification::ExternalAgentConfigImportCompleted(_) => { + ServerNotification::ExternalAgentConfigImportCompleted(notification) => { let should_report_completion = app_server_client.consume_external_agent_config_import_completion(); if let Err(err) = self.refresh_in_memory_config_from_disk().await { @@ -117,10 +117,8 @@ impl App { self.chat_widget.submit_op(AppCommand::reload_user_config()); self.fetch_plugins_list(app_server_client, cwd); if should_report_completion { - self.chat_widget.add_info_message( - crate::external_agent_config_migration_flow::EXTERNAL_AGENT_CONFIG_MIGRATION_FINISHED_MESSAGE - .to_string(), - /*hint*/ None, + self.chat_widget.add_plain_history_lines( + crate::external_agent_config_migration_flow::external_agent_config_migration_finished_lines(notification), ); } return; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 174c4d14e..280c556af 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -119,8 +119,8 @@ impl App { ) .await { - Ok(ExternalAgentConfigMigrationFlowOutcome::Started(message)) => { - self.chat_widget.add_info_message(message, /*hint*/ None); + Ok(ExternalAgentConfigMigrationFlowOutcome::Started(lines)) => { + self.chat_widget.add_plain_history_lines(lines); } Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems) => { self.chat_widget.add_info_message( diff --git a/codex-rs/tui/src/external_agent_config_migration.rs b/codex-rs/tui/src/external_agent_config_migration.rs index 9a7e8f329..995c97f51 100644 --- a/codex-rs/tui/src/external_agent_config_migration.rs +++ b/codex-rs/tui/src/external_agent_config_migration.rs @@ -1,5 +1,6 @@ use crate::diff_render::display_path_for; use crate::external_agent_config_migration_model::ExternalAgentConfigMigrationGroupModel; +use crate::external_agent_config_migration_model::external_agent_config_migration_count_summary; use crate::external_agent_config_migration_model::external_agent_config_migration_groups; use crate::external_agent_config_migration_model::external_agent_config_migration_item_detail; use crate::external_agent_config_migration_model::external_agent_config_migration_item_label; @@ -599,6 +600,13 @@ impl ExternalAgentConfigMigrationScreen { .iter() .enumerate() .flat_map(|(idx, group)| { + let selected_items = group + .item_indices + .iter() + .filter_map(|idx| self.items.get(*idx)) + .filter(|item| item.enabled) + .map(|item| &item.item); + let count_summary = external_agent_config_migration_count_summary(selected_items); [ RenderLineEntry { item_idx: Some(idx), @@ -614,6 +622,15 @@ impl ExternalAgentConfigMigrationScreen { kind: RenderLineKind::ItemDetail, line: Line::from(format!(" {}", group.description)), }, + RenderLineEntry { + item_idx: None, + kind: RenderLineKind::ItemDetail, + line: Line::from(if count_summary.is_empty() { + " Importing: none".to_string() + } else { + format!(" Importing: {count_summary}") + }), + }, ] }) .collect() diff --git a/codex-rs/tui/src/external_agent_config_migration_flow.rs b/codex-rs/tui/src/external_agent_config_migration_flow.rs index e8611d571..c1cef68df 100644 --- a/codex-rs/tui/src/external_agent_config_migration_flow.rs +++ b/codex-rs/tui/src/external_agent_config_migration_flow.rs @@ -2,29 +2,185 @@ use crate::app_server_session::AppServerSession; use crate::app_server_session::EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE; use crate::external_agent_config_migration::ExternalAgentConfigMigrationOutcome; use crate::external_agent_config_migration::run_external_agent_config_migration_prompt; +use crate::external_agent_config_migration_model::external_agent_config_migration_item_count; +use crate::external_agent_config_migration_model::external_agent_config_migration_type_label; use crate::legacy_core::config::Config; use crate::tui; use codex_app_server_protocol::ExternalAgentConfigDetectParams; +use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; +use codex_app_server_protocol::ExternalAgentConfigMigrationItem; +use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; +use ratatui::prelude::Stylize as _; +use ratatui::text::Line; -pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_FINISHED_MESSAGE: &str = - "Claude Code import finished. Run /import again to check for additional items."; pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE: &str = "No Claude Code setup was found to import."; pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE: &str = "Import from Claude Code is unavailable in remote sessions. Start Codex locally and run /import."; pub(crate) const EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE: &str = "Import from Claude Code is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import."; pub(crate) enum ExternalAgentConfigMigrationFlowOutcome { - Started(String), + Started(Vec>), NoItems, Cancelled, } -fn external_agent_config_migration_success_message(remaining_item_count: usize) -> String { - let message = "Claude Code import started. You can keep working while it finishes. Imported setup will apply to new chats."; - match remaining_items_handoff(remaining_item_count) { - Some(remaining_items_handoff) => format!("{message} {remaining_items_handoff}"), - None => message.to_string(), +fn external_agent_config_migration_started_lines( + selected_items: &[ExternalAgentConfigMigrationItem], + remaining_item_count: usize, +) -> Vec> { + let mut import_summaries = + Vec::<(ExternalAgentConfigMigrationItemType, usize, Vec<&str>)>::new(); + for item in selected_items { + let names = item + .details + .as_ref() + .map_or_else(Vec::new, |details| match item.item_type { + ExternalAgentConfigMigrationItemType::Plugins => details + .plugins + .iter() + .flat_map(|plugin_group| plugin_group.plugin_names.iter()) + .map(String::as_str) + .collect(), + ExternalAgentConfigMigrationItemType::Skills => details + .skills + .iter() + .map(|skill| skill.name.as_str()) + .collect(), + ExternalAgentConfigMigrationItemType::McpServerConfig => details + .mcp_servers + .iter() + .map(|server| server.name.as_str()) + .collect(), + ExternalAgentConfigMigrationItemType::Subagents => details + .subagents + .iter() + .map(|agent| agent.name.as_str()) + .collect(), + ExternalAgentConfigMigrationItemType::Hooks => details + .hooks + .iter() + .map(|hook| hook.name.as_str()) + .collect(), + ExternalAgentConfigMigrationItemType::Commands => details + .commands + .iter() + .map(|command| command.name.as_str()) + .collect(), + ExternalAgentConfigMigrationItemType::Sessions => details + .sessions + .iter() + .filter_map(|session| session.title.as_deref()) + .collect(), + ExternalAgentConfigMigrationItemType::AgentsMd + | ExternalAgentConfigMigrationItemType::Config => Vec::new(), + }); + let count = external_agent_config_migration_item_count(item); + if let Some((_, type_count, type_names)) = import_summaries + .iter_mut() + .find(|(item_type, _, _)| *item_type == item.item_type) + { + *type_count += count; + type_names.extend(names); + } else { + import_summaries.push((item.item_type, count, names)); + } } + + let mut lines = vec![ + vec![ + "• ".dim(), + "Claude Code import started.".cyan(), + " You can keep working while it finishes.".into(), + ] + .into(), + vec![" ".into(), "Imported setup will apply to new chats.".dim()].into(), + vec![" ".into(), "Importing:".cyan().bold()].into(), + ]; + lines.extend( + import_summaries + .into_iter() + .map(|(item_type, count, names)| { + let mut line = vec![ + " ".into(), + external_agent_config_migration_type_label(item_type).cyan(), + ": ".into(), + count.to_string().green(), + ]; + if !names.is_empty() { + let shown_names = names.iter().take(3).copied().collect::>(); + let mut name_summary = shown_names.join(", "); + if names.len() > shown_names.len() { + name_summary + .push_str(&format!(", +{} more", names.len() - shown_names.len())); + } + line.extend([" — ".dim(), name_summary.into()]); + } + line.into() + }), + ); + if let Some(remaining_items_handoff) = remaining_items_handoff(remaining_item_count) { + lines.push(vec![" ".into(), remaining_items_handoff.dim()].into()); + } + lines +} + +pub(crate) fn external_agent_config_migration_finished_lines( + notification: &ExternalAgentConfigImportCompletedNotification, +) -> Vec> { + let imported_count = notification + .item_type_results + .iter() + .map(|type_result| type_result.successes.len()) + .sum::(); + let failed_count = notification + .item_type_results + .iter() + .map(|type_result| type_result.failures.len()) + .sum::(); + let failed_count = if failed_count == 0 { + format!("{failed_count} failed").green() + } else { + format!("{failed_count} failed").red() + }; + let mut lines = vec![ + vec![ + "• ".dim(), + "Claude Code import finished: ".into(), + format!("{imported_count} imported").green(), + ", ".into(), + failed_count, + ".".into(), + ] + .into(), + ]; + if !notification.item_type_results.is_empty() { + lines.push(vec![" ".into(), "Results by type:".cyan().bold()].into()); + lines.extend(notification.item_type_results.iter().map(|type_result| { + let failed_count = format!("{} failed", type_result.failures.len()); + let failed_count = if type_result.failures.is_empty() { + failed_count.green() + } else { + failed_count.red() + }; + vec![ + " ".into(), + external_agent_config_migration_type_label(type_result.item_type).cyan(), + ": ".into(), + format!("{} imported", type_result.successes.len()).green(), + ", ".into(), + failed_count, + ] + .into() + })); + } + lines.push( + vec![ + " ".into(), + "Run /import again to check for additional items.".dim(), + ] + .into(), + ); + lines } fn remaining_items_handoff(remaining_item_count: usize) -> Option { @@ -96,10 +252,12 @@ pub(crate) async fn handle_external_agent_config_migration_prompt( Ok(()) => { let remaining_item_count = detected_items.len().saturating_sub(selected_items.len()); - let success_message = - external_agent_config_migration_success_message(remaining_item_count); + let started_lines = external_agent_config_migration_started_lines( + &selected_items, + remaining_item_count, + ); return Ok(ExternalAgentConfigMigrationFlowOutcome::Started( - success_message, + started_lines, )); } Err(err) => { diff --git a/codex-rs/tui/src/external_agent_config_migration_flow_tests.rs b/codex-rs/tui/src/external_agent_config_migration_flow_tests.rs index 913b55607..9a241eee7 100644 --- a/codex-rs/tui/src/external_agent_config_migration_flow_tests.rs +++ b/codex-rs/tui/src/external_agent_config_migration_flow_tests.rs @@ -1,21 +1,259 @@ use super::*; +use codex_app_server_protocol::ExternalAgentConfigImportItemTypeFailure; +use codex_app_server_protocol::ExternalAgentConfigImportItemTypeSuccess; +use codex_app_server_protocol::ExternalAgentConfigImportTypeResult; +use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; +use codex_app_server_protocol::McpServerMigration; +use codex_app_server_protocol::MigrationDetails; +use codex_app_server_protocol::PluginsMigration; +use codex_app_server_protocol::SessionMigration; +use codex_app_server_protocol::SkillMigration; +use pretty_assertions::assert_eq; +use ratatui::text::Line; +use std::path::PathBuf; + +fn selected_items() -> Vec { + vec![ + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Config, + description: "Import settings".to_string(), + cwd: None, + details: None, + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Skills, + description: "Import skills".to_string(), + cwd: None, + details: Some(MigrationDetails { + skills: vec![ + SkillMigration { + name: "triage".to_string(), + }, + SkillMigration { + name: "release-notes".to_string(), + }, + SkillMigration { + name: "risk-check".to_string(), + }, + SkillMigration { + name: "incident-review".to_string(), + }, + ], + ..Default::default() + }), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::McpServerConfig, + description: "Import MCP servers".to_string(), + cwd: None, + details: Some(MigrationDetails { + mcp_servers: vec![ + McpServerMigration { + name: "docs".to_string(), + }, + McpServerMigration { + name: "issues".to_string(), + }, + ], + ..Default::default() + }), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Sessions, + description: "Import chat sessions".to_string(), + cwd: None, + details: Some(MigrationDetails { + sessions: vec![ + SessionMigration { + path: PathBuf::from("/sessions/alpha.jsonl"), + cwd: PathBuf::from("/workspace/project"), + title: Some("Alpha rollout".to_string()), + }, + SessionMigration { + path: PathBuf::from("/sessions/beta.jsonl"), + cwd: PathBuf::from("/workspace/project"), + title: Some("Beta review".to_string()), + }, + SessionMigration { + path: PathBuf::from("/sessions/gamma.jsonl"), + cwd: PathBuf::from("/workspace/project"), + title: Some("Gamma notes".to_string()), + }, + ], + ..Default::default() + }), + }, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Import plugins".to_string(), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "example".to_string(), + plugin_names: vec!["formatter".to_string(), "reviewer".to_string()], + }], + ..Default::default() + }), + }, + ] +} + +fn completed_notification() -> ExternalAgentConfigImportCompletedNotification { + ExternalAgentConfigImportCompletedNotification { + import_id: "import-1".to_string(), + item_type_results: vec![ + ExternalAgentConfigImportTypeResult { + item_type: ExternalAgentConfigMigrationItemType::Config, + successes: vec![ExternalAgentConfigImportItemTypeSuccess { + item_type: ExternalAgentConfigMigrationItemType::Config, + cwd: None, + source: Some("settings.json".to_string()), + target: Some("config.toml".to_string()), + }], + failures: Vec::new(), + }, + ExternalAgentConfigImportTypeResult { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + successes: vec![ExternalAgentConfigImportItemTypeSuccess { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + cwd: None, + source: Some("formatter@example".to_string()), + target: Some("formatter@example".to_string()), + }], + failures: vec![ExternalAgentConfigImportItemTypeFailure { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + error_type: Some("plugin_install_failed".to_string()), + failure_stage: "plugin_import".to_string(), + message: "install failed".to_string(), + cwd: Some(PathBuf::from("/workspace/project")), + source: Some("deployer@example".to_string()), + }], + }, + ], + } +} #[test] fn external_agent_config_migration_messages_snapshot() { - let cases = [0, 1, 2]; - - let messages = cases - .map(external_agent_config_migration_success_message) + let selected_items = selected_items(); + let completed_notification = completed_notification(); + let messages = [0, 1, 2] .into_iter() + .flat_map(|remaining_item_count| { + external_agent_config_migration_started_lines(&selected_items, remaining_item_count) + }) + .chain(external_agent_config_migration_finished_lines( + &completed_notification, + )) .chain([ - EXTERNAL_AGENT_CONFIG_MIGRATION_FINISHED_MESSAGE.to_string(), - EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE.to_string(), - EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE.to_string(), - EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE.to_string(), - EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE.to_string(), + Line::from(EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE), + Line::from(EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE), + Line::from(EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE), + Line::from(EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE), ]) + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + }) .collect::>() .join("\n"); insta::assert_snapshot!("external_agent_config_migration_messages", messages); } + +#[test] +fn external_agent_config_migration_status_lines_use_semantic_colors() { + assert_eq!( + external_agent_config_migration_started_lines( + &selected_items(), + /*remaining_item_count*/ 0, + ), + vec![ + Line::from(vec![ + "• ".dim(), + "Claude Code import started.".cyan(), + " You can keep working while it finishes.".into(), + ]), + Line::from(vec![ + " ".into(), + "Imported setup will apply to new chats.".dim(), + ]), + Line::from(vec![" ".into(), "Importing:".cyan().bold()]), + Line::from(vec![ + " ".into(), + "Settings".cyan(), + ": ".into(), + "1".green(), + ]), + Line::from(vec![ + " ".into(), + "Skills".cyan(), + ": ".into(), + "4".green(), + " — ".dim(), + "triage, release-notes, risk-check, +1 more".into(), + ]), + Line::from(vec![ + " ".into(), + "MCP servers".cyan(), + ": ".into(), + "2".green(), + " — ".dim(), + "docs, issues".into(), + ]), + Line::from(vec![ + " ".into(), + "Chat sessions".cyan(), + ": ".into(), + "3".green(), + " — ".dim(), + "Alpha rollout, Beta review, Gamma notes".into(), + ]), + Line::from(vec![ + " ".into(), + "Plugins".cyan(), + ": ".into(), + "2".green(), + " — ".dim(), + "formatter, reviewer".into(), + ]), + ] + ); + + assert_eq!( + external_agent_config_migration_finished_lines(&completed_notification()), + vec![ + Line::from(vec![ + "• ".dim(), + "Claude Code import finished: ".into(), + "2 imported".green(), + ", ".into(), + "1 failed".red(), + ".".into(), + ]), + Line::from(vec![" ".into(), "Results by type:".cyan().bold()]), + Line::from(vec![ + " ".into(), + "Settings".cyan(), + ": ".into(), + "1 imported".green(), + ", ".into(), + "0 failed".green(), + ]), + Line::from(vec![ + " ".into(), + "Plugins".cyan(), + ": ".into(), + "1 imported".green(), + ", ".into(), + "1 failed".red(), + ]), + Line::from(vec![ + " ".into(), + "Run /import again to check for additional items.".dim(), + ]), + ] + ); +} diff --git a/codex-rs/tui/src/external_agent_config_migration_model.rs b/codex-rs/tui/src/external_agent_config_migration_model.rs index d03e238da..87d3310b3 100644 --- a/codex-rs/tui/src/external_agent_config_migration_model.rs +++ b/codex-rs/tui/src/external_agent_config_migration_model.rs @@ -91,12 +91,107 @@ pub(crate) fn external_agent_config_migration_item_label( } } +pub(crate) fn external_agent_config_migration_type_label( + item_type: ExternalAgentConfigMigrationItemType, +) -> &'static str { + match item_type { + ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions", + ExternalAgentConfigMigrationItemType::Config => "Settings", + ExternalAgentConfigMigrationItemType::Skills => "Skills", + ExternalAgentConfigMigrationItemType::Plugins => "Plugins", + ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP servers", + ExternalAgentConfigMigrationItemType::Subagents => "Agents", + ExternalAgentConfigMigrationItemType::Hooks => "Hooks", + ExternalAgentConfigMigrationItemType::Commands => "Slash commands", + ExternalAgentConfigMigrationItemType::Sessions => "Chat sessions", + } +} + +/// Summarizes the concrete objects represented by selected migration items. +/// +/// Most detected item types carry the objects they will import in `details`; types without +/// details represent one importable file or source directory per migration item. +pub(crate) fn external_agent_config_migration_count_summary<'a>( + items: impl IntoIterator, +) -> String { + let mut counts = Vec::<(ExternalAgentConfigMigrationItemType, usize)>::new(); + for item in items { + let count = external_agent_config_migration_item_count(item); + if let Some((_, type_count)) = counts + .iter_mut() + .find(|(item_type, _)| *item_type == item.item_type) + { + *type_count += count; + } else { + counts.push((item.item_type, count)); + } + } + + counts + .into_iter() + .map(|(item_type, count)| { + format!( + "{} {count}", + external_agent_config_migration_type_label(item_type) + ) + }) + .collect::>() + .join(", ") +} + +pub(crate) fn external_agent_config_migration_item_count( + item: &ExternalAgentConfigMigrationItem, +) -> usize { + match item.item_type { + ExternalAgentConfigMigrationItemType::Plugins => { + item.details.as_ref().map_or(1, |details| { + details + .plugins + .iter() + .map(|plugin_group| plugin_group.plugin_names.len()) + .sum() + }) + } + ExternalAgentConfigMigrationItemType::McpServerConfig => item + .details + .as_ref() + .map_or(1, |details| details.mcp_servers.len()), + ExternalAgentConfigMigrationItemType::Subagents => item + .details + .as_ref() + .map_or(1, |details| details.subagents.len()), + ExternalAgentConfigMigrationItemType::Hooks => item + .details + .as_ref() + .map_or(1, |details| details.hooks.len()), + ExternalAgentConfigMigrationItemType::Commands => item + .details + .as_ref() + .map_or(1, |details| details.commands.len()), + ExternalAgentConfigMigrationItemType::Sessions => item + .details + .as_ref() + .map_or(1, |details| details.sessions.len()), + ExternalAgentConfigMigrationItemType::Skills => item + .details + .as_ref() + .map_or(1, |details| details.skills.len()), + ExternalAgentConfigMigrationItemType::AgentsMd + | ExternalAgentConfigMigrationItemType::Config => 1, + } +} + pub(crate) fn external_agent_config_migration_item_detail( item: &ExternalAgentConfigMigrationItem, ) -> Option { let details = item.details.as_ref()?; match item.item_type { ExternalAgentConfigMigrationItemType::Plugins => None, + ExternalAgentConfigMigrationItemType::Skills => Some(format_counted_details( + "skill", + details.skills.len(), + details.skills.iter().map(|skill| skill.name.as_str()), + )), ExternalAgentConfigMigrationItemType::McpServerConfig => Some(format_counted_details( "MCP server", details.mcp_servers.len(), @@ -129,8 +224,7 @@ pub(crate) fn external_agent_config_migration_item_detail( .filter_map(|session| session.title.as_deref()), )), ExternalAgentConfigMigrationItemType::AgentsMd - | ExternalAgentConfigMigrationItemType::Config - | ExternalAgentConfigMigrationItemType::Skills => None, + | ExternalAgentConfigMigrationItemType::Config => None, } } 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 d7274eefc..165473c5a 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 @@ -10,10 +10,13 @@ expression: rendered Standard Claude Chat data cannot be imported. [x] Tools & setup Settings, instructions, integrations, agents, commands, and skills + Importing: Settings 1 [x] Current project Add Codex files alongside your existing project files + Importing: Plugins 6, Instructions 1 [x] Chat sessions (1) Last 30 days of chats + Importing: Chat sessions 1 Selected 4 of 4 items. › 1. Import selected diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap index d7274eefc..165473c5a 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration__tests__external_agent_config_migration_prompt_windows.snap @@ -10,10 +10,13 @@ expression: rendered Standard Claude Chat data cannot be imported. [x] Tools & setup Settings, instructions, integrations, agents, commands, and skills + Importing: Settings 1 [x] Current project Add Codex files alongside your existing project files + Importing: Plugins 6, Instructions 1 [x] Chat sessions (1) Last 30 days of chats + Importing: Chat sessions 1 Selected 4 of 4 items. › 1. Import selected diff --git a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_flow__tests__external_agent_config_migration_messages.snap b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_flow__tests__external_agent_config_migration_messages.snap index ad3d3ac06..51367fe90 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_flow__tests__external_agent_config_migration_messages.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__external_agent_config_migration_flow__tests__external_agent_config_migration_messages.snap @@ -2,10 +2,37 @@ source: tui/src/external_agent_config_migration_flow_tests.rs expression: messages --- -Claude Code import started. You can keep working while it finishes. Imported setup will apply to new chats. -Claude Code import started. You can keep working while it finishes. Imported setup will apply to new chats. 1 additional item remains. After it finishes, run /import again to review it. -Claude Code import started. You can keep working while it finishes. Imported setup will apply to new chats. 2 additional items remain. After it finishes, run /import again to review them. -Claude Code import finished. Run /import again to check for additional items. +• Claude Code import started. You can keep working while it finishes. + Imported setup will apply to new chats. + Importing: + Settings: 1 + Skills: 4 — triage, release-notes, risk-check, +1 more + MCP servers: 2 — docs, issues + Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes + Plugins: 2 — formatter, reviewer +• Claude Code import started. You can keep working while it finishes. + Imported setup will apply to new chats. + Importing: + Settings: 1 + Skills: 4 — triage, release-notes, risk-check, +1 more + MCP servers: 2 — docs, issues + Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes + Plugins: 2 — formatter, reviewer + 1 additional item remains. After it finishes, run /import again to review it. +• Claude Code import started. You can keep working while it finishes. + Imported setup will apply to new chats. + Importing: + Settings: 1 + Skills: 4 — triage, release-notes, risk-check, +1 more + MCP servers: 2 — docs, issues + Chat sessions: 3 — Alpha rollout, Beta review, Gamma notes + Plugins: 2 — formatter, reviewer + 2 additional items remain. After it finishes, run /import again to review them. +• Claude Code import finished: 2 imported, 1 failed. + Results by type: + Settings: 1 imported, 0 failed + Plugins: 1 imported, 1 failed + Run /import again to check for additional items. No Claude Code setup was found to import. Import from Claude Code is unavailable in remote sessions. Start Codex locally and run /import. Import from Claude Code is unavailable while Codex is connected to the local app-server daemon. Stop the daemon, restart Codex, and run /import.