mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why Users need to understand what external-agent data Codex detected, what is selected, and how to proceed before an import begins. The updated picker makes focus, selection state, and the submission path explicit while preserving the existing import backend. ## What changed - replace the old migration prompt with a two-step external-agent import picker - add a customize view with explicit item focus, selection state, counts, and a review action - separate detected import data into a view model - add Unix and Windows snapshots for prompt, item-focus, and action-focus states ## Validation - `just test -p codex-tui external_agent_config_migration` (10 passed) - manually exercised an isolated TUI fixture covering customization, selection toggles, review, import, repeated invocation, and session resume - the broader `just test -p codex-tui` run passed 2,805 tests, with 2 unrelated guardian feature-flag failures and 4 skipped tests ## Review note This is the largest layer in the stack because the interaction state, rendering changes, and required snapshots move together. It remains a draft in case reviewers prefer a further presentation/state split. ## Stack 1. [#27064](https://github.com/openai/codex/pull/27064): remove the startup migration flow 2. [#27065](https://github.com/openai/codex/pull/27065): extract the picker renderer 3. [#27070](https://github.com/openai/codex/pull/27070): add the external-agent import picker UX 4. [#27071](https://github.com/openai/codex/pull/27071): expose the flow through `/import` **This PR is stack item 3.** Draft while the lower stack dependencies are reviewed.
148 lines
5.5 KiB
Rust
148 lines
5.5 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_item_detail(
|
|
item: &ExternalAgentConfigMigrationItem,
|
|
) -> Option<String> {
|
|
let details = item.details.as_ref()?;
|
|
match item.item_type {
|
|
ExternalAgentConfigMigrationItemType::Plugins => None,
|
|
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
|
|
| ExternalAgentConfigMigrationItemType::Skills => 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(", ")),
|
|
}
|
|
}
|