From 3694b48a82343d94b58c62fa9af335710c3a6187 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 24 Jun 2026 08:56:57 -0700 Subject: [PATCH] [codex] show external import result counts (#29567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed - Show per-type import counts in the `/import` review UI and started message. - Render completion results as a multi-line summary with total imported/failed counts and one row per import type. - Add snapshot coverage for the updated review and completion output. Screenshot 2026-06-23 at 9 41 20 PM ## Why The TUI previously only reported that Claude Code import started or finished. Users could not see how many items of each type were selected or how many actually imported versus failed. --- .../schema/json/ClientRequest.json | 18 ++ .../codex_app_server_protocol.schemas.json | 18 ++ .../codex_app_server_protocol.v2.schemas.json | 18 ++ .../v2/ExternalAgentConfigDetectResponse.json | 18 ++ .../v2/ExternalAgentConfigImportParams.json | 18 ++ .../schema/typescript/v2/MigrationDetails.ts | 3 +- .../schema/typescript/v2/SkillMigration.ts | 5 + .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/config.rs | 9 + codex-rs/app-server/README.md | 4 +- .../src/config/external_agent_config.rs | 22 +- .../src/config/external_agent_config_tests.rs | 10 +- .../external_agent_config_processor.rs | 11 + codex-rs/tui/src/app/app_server_events.rs | 8 +- codex-rs/tui/src/app/event_dispatch.rs | 4 +- .../src/external_agent_config_migration.rs | 17 ++ .../external_agent_config_migration_flow.rs | 180 +++++++++++- ...ernal_agent_config_migration_flow_tests.rs | 256 +++++++++++++++++- .../external_agent_config_migration_model.rs | 98 ++++++- ...xternal_agent_config_migration_prompt.snap | 3 + ...agent_config_migration_prompt_windows.snap | 3 + ...ernal_agent_config_migration_messages.snap | 35 ++- 22 files changed, 714 insertions(+), 45 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SkillMigration.ts 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.