Files
codex/codex-rs/tui/src/external_agent_config_migration_model.rs
T
charlesgong-openai 3694b48a82 [codex] show external import result counts (#29567)
## 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.

<img width="537" height="322" alt="Screenshot 2026-06-23 at 9 41 20 PM"
src="https://github.com/user-attachments/assets/166542eb-2097-4b2b-8130-8f6fd8c680ce"
/>


## 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.
2026-06-24 08:56:57 -07:00

242 lines
9.1 KiB
Rust

use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use std::collections::BTreeSet;
#[derive(Clone, Debug)]
pub(crate) struct ExternalAgentConfigMigrationGroupModel {
pub(crate) label: String,
pub(crate) description: &'static str,
pub(crate) item_indices: Vec<usize>,
}
pub(crate) fn external_agent_config_migration_groups(
items: &[ExternalAgentConfigMigrationItem],
) -> Vec<ExternalAgentConfigMigrationGroupModel> {
let tools_and_setup = items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
(item.cwd.is_none() && item.item_type != ExternalAgentConfigMigrationItemType::Sessions)
.then_some(idx)
})
.collect::<Vec<_>>();
let projects = items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
(item.cwd.is_some() && item.item_type != ExternalAgentConfigMigrationItemType::Sessions)
.then_some(idx)
})
.collect::<Vec<_>>();
let chat_sessions = items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
(item.item_type == ExternalAgentConfigMigrationItemType::Sessions).then_some(idx)
})
.collect::<Vec<_>>();
let mut groups = Vec::new();
if !tools_and_setup.is_empty() {
groups.push(ExternalAgentConfigMigrationGroupModel {
label: "Tools & setup".to_string(),
description: "Settings, instructions, integrations, agents, commands, and skills",
item_indices: tools_and_setup,
});
}
if !projects.is_empty() {
let project_count = projects
.iter()
.filter_map(|idx| items[*idx].cwd.as_deref())
.collect::<BTreeSet<_>>()
.len();
groups.push(ExternalAgentConfigMigrationGroupModel {
label: if project_count == 1 {
"Current project".to_string()
} else {
format!("Projects ({project_count})")
},
description: "Add Codex files alongside your existing project files",
item_indices: projects,
});
}
if !chat_sessions.is_empty() {
let session_count = chat_sessions
.iter()
.filter_map(|idx| items[*idx].details.as_ref())
.map(|details| details.sessions.len())
.sum::<usize>();
groups.push(ExternalAgentConfigMigrationGroupModel {
label: format!("Chat sessions ({session_count})"),
description: "Last 30 days of chats",
item_indices: chat_sessions,
});
}
groups
}
pub(crate) fn external_agent_config_migration_item_label(
item: &ExternalAgentConfigMigrationItem,
) -> &'static str {
match item.item_type {
ExternalAgentConfigMigrationItemType::AgentsMd => "Instructions (CLAUDE.md -> AGENTS.md)",
ExternalAgentConfigMigrationItemType::Config => "Settings (settings.json -> config.toml)",
ExternalAgentConfigMigrationItemType::Skills => "Skills",
ExternalAgentConfigMigrationItemType::Plugins => "Plugins",
ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP servers",
ExternalAgentConfigMigrationItemType::Subagents => "Agents",
ExternalAgentConfigMigrationItemType::Hooks => "Hooks",
ExternalAgentConfigMigrationItemType::Commands => "Slash commands",
ExternalAgentConfigMigrationItemType::Sessions => "Recent chat sessions",
}
}
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<Item = &'a ExternalAgentConfigMigrationItem>,
) -> 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::<Vec<_>>()
.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<String> {
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(),
details
.mcp_servers
.iter()
.map(|server| server.name.as_str()),
)),
ExternalAgentConfigMigrationItemType::Subagents => Some(format_counted_details(
"agent",
details.subagents.len(),
details.subagents.iter().map(|agent| agent.name.as_str()),
)),
ExternalAgentConfigMigrationItemType::Hooks => Some(format_counted_details(
"hook",
details.hooks.len(),
details.hooks.iter().map(|hook| hook.name.as_str()),
)),
ExternalAgentConfigMigrationItemType::Commands => Some(format_counted_details(
"slash command",
details.commands.len(),
details.commands.iter().map(|command| command.name.as_str()),
)),
ExternalAgentConfigMigrationItemType::Sessions => Some(format_counted_details(
"chat session",
details.sessions.len(),
details
.sessions
.iter()
.filter_map(|session| session.title.as_deref()),
)),
ExternalAgentConfigMigrationItemType::AgentsMd
| ExternalAgentConfigMigrationItemType::Config => None,
}
}
fn format_counted_details<'a>(
noun: &str,
count: usize,
names: impl Iterator<Item = &'a str>,
) -> String {
let suffix = if count == 1 { "" } else { "s" };
match names.take(4).collect::<Vec<_>>() {
names if names.is_empty() => format!("{count} {noun}{suffix}"),
names => format!("{count} {noun}{suffix}: {}", names.join(", ")),
}
}