WIP: checkpoint CLI import UX integration

This commit is contained in:
stefanstokic-oai
2026-06-08 13:37:50 -04:00
parent b128da272e
commit 95da88699c
25 changed files with 1025 additions and 1036 deletions
+8
View File
@@ -178,6 +178,7 @@ pub(crate) fn server_notification_requires_delivery(notification: &ServerNotific
ServerNotification::TurnCompleted(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ItemCompleted(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::AgentMessageDelta(_)
| ServerNotification::PlanDelta(_)
| ServerNotification::ReasoningSummaryTextDelta(_)
@@ -2159,6 +2160,13 @@ mod tests {
)
)
));
assert!(event_requires_delivery(
&InProcessServerEvent::ServerNotification(
codex_app_server_protocol::ServerNotification::ExternalAgentConfigImportCompleted(
codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification {},
)
)
));
assert!(!event_requires_delivery(&InProcessServerEvent::Lagged {
skipped: 1
}));
+9 -1
View File
@@ -104,7 +104,9 @@ type PendingClientRequestResponse = std::result::Result<Result, JSONRPCErrorErro
fn server_notification_requires_delivery(notification: &ServerNotification) -> bool {
matches!(
notification,
ServerNotification::TurnCompleted(_) | ServerNotification::ThreadSettingsUpdated(_)
ServerNotification::TurnCompleted(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
)
}
@@ -729,6 +731,7 @@ mod tests {
use super::*;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::SessionSource as ApiSessionSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
@@ -893,5 +896,10 @@ mod tests {
},
})
));
assert!(server_notification_requires_delivery(
&ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
)
));
}
}
+8
View File
@@ -2599,6 +2599,14 @@ mod tests {
);
}
#[test]
fn import_remains_an_interactive_prompt() {
let cli = MultitoolCli::try_parse_from(["codex", "import"]).expect("parse");
assert!(cli.subcommand.is_none());
assert_eq!(cli.interactive.prompt.as_deref(), Some("import"));
}
#[test]
fn profile_v2_rejects_non_plain_names_at_parse_time() {
assert!(
+3 -6
View File
@@ -177,7 +177,8 @@ pub enum Feature {
RemotePlugin,
/// Enable remote plugin sharing flows.
PluginSharing,
/// Show the startup prompt for migrating external agent config into Codex.
/// Removed compatibility flag retained as a no-op now that Claude Code import is
/// available explicitly through `/import`.
ExternalMigration,
/// Allow the model to invoke the built-in image generation tool.
ImageGeneration,
@@ -1071,11 +1072,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::ExternalMigration,
key: "external_migration",
stage: Stage::Experimental {
name: "External migration",
menu_description: "Show a startup prompt when Codex detects migratable external agent config for this machine or project.",
announcement: "",
},
stage: Stage::Removed,
default_enabled: false,
},
FeatureSpec {
+10 -17
View File
@@ -83,6 +83,16 @@ fn plugin_hooks_is_removed_and_disabled_by_default() {
assert_eq!(feature_for_key("plugin_hooks"), Some(Feature::PluginHooks));
}
#[test]
fn external_migration_is_removed_and_disabled_by_default() {
assert_eq!(Feature::ExternalMigration.stage(), Stage::Removed);
assert_eq!(Feature::ExternalMigration.default_enabled(), false);
assert_eq!(
feature_for_key("external_migration"),
Some(Feature::ExternalMigration)
);
}
#[test]
fn code_mode_only_requires_code_mode() {
let mut features = Features::with_defaults();
@@ -101,23 +111,6 @@ fn guardian_approval_is_stable_and_enabled_by_default() {
assert_eq!(Feature::GuardianApproval.default_enabled(), true);
}
#[test]
fn external_migration_is_experimental_and_disabled_by_default() {
let spec = Feature::ExternalMigration.info();
let stage = spec.stage;
assert!(matches!(stage, Stage::Experimental { .. }));
assert_eq!(stage.experimental_menu_name(), Some("External migration"));
assert_eq!(
stage.experimental_menu_description(),
Some(
"Show a startup prompt when Codex detects migratable external agent config for this machine or project."
)
);
assert_eq!(stage.experimental_announcement(), None);
assert_eq!(Feature::ExternalMigration.default_enabled(), false);
}
#[test]
fn request_permissions_is_under_development() {
assert_eq!(
-39
View File
@@ -36,8 +36,6 @@ use crate::cwd_prompt::CwdPromptAction;
use crate::diff_render::DiffSummary;
use crate::exec_command::split_command_string;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::external_agent_config_migration_startup::ExternalAgentConfigMigrationStartupOutcome;
use crate::external_agent_config_migration_startup::handle_external_agent_config_migration_prompt_if_needed;
use crate::external_editor;
use crate::file_search::FileSearchManager;
use crate::history_cell;
@@ -725,7 +723,6 @@ impl App {
session_selection: SessionSelection,
feedback: codex_feedback::CodexFeedback,
is_first_run: bool,
entered_trust_nux: bool,
should_prompt_windows_sandbox_nux_at_startup: bool,
app_server_target: AppServerTarget,
state_db: Option<StateDbHandle>,
@@ -747,38 +744,6 @@ impl App {
let harness_overrides =
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
let external_agent_config_migration_outcome =
handle_external_agent_config_migration_prompt_if_needed(
tui,
&mut app_server,
&mut config,
&cli_kv_overrides,
&harness_overrides,
entered_trust_nux,
)
.await?;
let external_agent_config_migration_message = match external_agent_config_migration_outcome
{
ExternalAgentConfigMigrationStartupOutcome::Continue { success_message } => {
success_message
}
ExternalAgentConfigMigrationStartupOutcome::ExitRequested => {
app_server
.shutdown()
.await
.inspect_err(|err| {
tracing::warn!("app-server shutdown failed: {err}");
})
.ok();
return Ok(AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}
};
let bootstrap = match startup_bootstrap {
Some(bootstrap) => bootstrap,
None => app_server.bootstrap(&config).await?,
@@ -974,10 +939,6 @@ impl App {
};
chat_widget.remote_connection = remote_connection;
let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis();
if let Some(message) = external_agent_config_migration_message {
chat_widget.add_info_message(message, /*hint*/ None);
}
chat_widget
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
+10 -1
View File
@@ -93,16 +93,25 @@ impl App {
return;
}
ServerNotification::ExternalAgentConfigImportCompleted(_) => {
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
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 {
tracing::warn!(
error = %err,
"failed to refresh config after external agent config import"
);
}
let cwd = self.chat_widget.config_ref().cwd.to_path_buf();
self.chat_widget.refresh_plugin_mentions();
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,
);
}
return;
}
ServerNotification::AppListUpdated(notification) => {
+26
View File
@@ -6,6 +6,7 @@
use super::resize_reflow::trailing_run_start;
use super::*;
use crate::config_update::format_config_error;
use crate::external_agent_config_migration_flow::ExternalAgentConfigMigrationFlowOutcome;
#[cfg(target_os = "windows")]
use codex_config::types::WindowsSandboxModeToml;
@@ -110,6 +111,31 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::OpenExternalAgentConfigMigration => {
match crate::external_agent_config_migration_flow::handle_external_agent_config_migration_prompt(
tui,
app_server,
&self.config,
)
.await
{
Ok(ExternalAgentConfigMigrationFlowOutcome::Started(message)) => {
self.chat_widget.add_info_message(message, /*hint*/ None);
}
Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems) => {
self.chat_widget.add_info_message(
crate::external_agent_config_migration_flow::EXTERNAL_AGENT_CONFIG_MIGRATION_NO_ITEMS_MESSAGE
.to_string(),
/*hint*/ None,
);
}
Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled) => {}
Err(error_message) => {
self.chat_widget.add_error_message(error_message);
}
}
tui.frame_requester().schedule_frame();
}
AppEvent::ResumeSessionByIdOrName(id_or_name) => {
match crate::lookup_session_target_with_app_server(app_server, &id_or_name).await? {
Some(target_session) => {
+3
View File
@@ -206,6 +206,9 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session.
OpenResumePicker,
/// Open the Claude Code migration picker inside the running TUI session.
OpenExternalAgentConfigMigration,
/// Resume a thread by UUID or thread name inside the running TUI session.
ResumeSessionByIdOrName(String),
+41 -4
View File
@@ -128,12 +128,16 @@ use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use uuid::Uuid;
const JSONRPC_INVALID_REQUEST: i64 = -32600;
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
pub(crate) const EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE: &str =
"A previous Claude Code import is still running. Wait for it to finish before importing again.";
const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update";
fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report {
@@ -176,6 +180,7 @@ pub(crate) struct AppServerSession {
thread_settings_update_supported: bool,
default_model: Option<String>,
available_models: Vec<ModelPreset>,
external_agent_config_import_completion_pending: AtomicBool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -219,6 +224,7 @@ impl AppServerSession {
thread_settings_update_supported: true,
default_model: None,
available_models: Vec::new(),
external_agent_config_import_completion_pending: AtomicBool::new(false),
}
}
@@ -235,6 +241,10 @@ impl AppServerSession {
matches!(self.thread_params_mode, ThreadParamsMode::Remote)
}
pub(crate) fn uses_embedded_app_server(&self) -> bool {
matches!(&self.client, AppServerClient::InProcess(_))
}
pub(crate) fn server_version(&self) -> Option<&str> {
let AppServerClient::Remote(client) = &self.client else {
return None;
@@ -357,21 +367,48 @@ impl AppServerSession {
self.client
.request_typed(ClientRequest::ExternalAgentConfigDetect { request_id, params })
.await
.wrap_err("externalAgentConfig/detect failed during TUI startup")
.wrap_err("externalAgentConfig/detect failed during Claude Code import")
}
pub(crate) async fn external_agent_config_import(
&mut self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
) -> Result<ExternalAgentConfigImportResponse> {
) -> Result<()> {
// Mark the import active before sending the request so a fast completion notification
// cannot arrive before the TUI records it.
if self
.external_agent_config_import_completion_pending
.swap(true, Ordering::Relaxed)
{
color_eyre::eyre::bail!(EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE);
}
let request_id = self.next_request_id();
self.client
let response: Result<ExternalAgentConfigImportResponse> = self
.client
.request_typed(ClientRequest::ExternalAgentConfigImport {
request_id,
params: ExternalAgentConfigImportParams { migration_items },
})
.await
.wrap_err("externalAgentConfig/import failed during TUI startup")
.wrap_err("externalAgentConfig/import failed during Claude Code import");
match response {
Ok(_) => Ok(()),
Err(err) => {
self.external_agent_config_import_completion_pending
.store(false, Ordering::Relaxed);
Err(err)
}
}
}
pub(crate) fn external_agent_config_import_in_progress(&self) -> bool {
self.external_agent_config_import_completion_pending
.load(Ordering::Relaxed)
}
pub(crate) fn consume_external_agent_config_import_completion(&self) -> bool {
self.external_agent_config_import_completion_pending
.swap(false, Ordering::Relaxed)
}
pub(crate) async fn next_event(&mut self) -> Option<AppServerEvent> {
@@ -406,6 +406,10 @@ impl ChatWidget {
SlashCommand::Skills => {
self.open_skills_menu();
}
SlashCommand::Import => {
self.app_event_tx
.send(AppEvent::OpenExternalAgentConfigMigration);
}
SlashCommand::Hooks => {
self.add_hooks_output();
}
@@ -1018,6 +1022,7 @@ impl ChatWidget {
| SlashCommand::Logout
| SlashCommand::Mention
| SlashCommand::Skills
| SlashCommand::Import
| SlashCommand::Hooks
| SlashCommand::Title
| SlashCommand::Statusline
@@ -1882,6 +1882,18 @@ async fn slash_resume_opens_picker() {
assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker));
}
#[tokio::test]
async fn slash_import_opens_claude_code_import_picker() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::Import);
assert_matches!(
rx.try_recv(),
Ok(AppEvent::OpenExternalAgentConfigMigration)
);
}
#[tokio::test]
async fn slash_archive_confirmation_requests_current_thread_archive() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -1,10 +1,8 @@
use crate::diff_render::display_path_for;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::selection_list::selection_option_row_with_dim;
use crate::style::accent_style;
use crate::external_agent_config_migration_model::ExternalAgentConfigMigrationGroupModel;
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;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
@@ -14,25 +12,16 @@ use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::prelude::Stylize as _;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio_stream::StreamExt;
mod render;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ExternalAgentConfigMigrationOutcome {
Proceed(Vec<ExternalAgentConfigMigrationItem>),
Skip,
SkipForever,
Exit,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -44,34 +33,26 @@ enum FocusArea {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ActionMenuOption {
Proceed,
Customize,
Skip,
SkipForever,
Back,
}
impl ActionMenuOption {
fn label(self) -> &'static str {
match self {
Self::Proceed => "Proceed with selected",
Self::Skip => "Skip for now",
Self::SkipForever => "Don't ask again",
Self::Proceed => "Import selected",
Self::Customize => "Customize selection",
Self::Skip => "Cancel",
Self::Back => "Done customizing",
}
}
}
fn previous(self) -> Option<Self> {
match self {
Self::Proceed => None,
Self::Skip => Some(Self::Proceed),
Self::SkipForever => Some(Self::Skip),
}
}
fn next(self) -> Option<Self> {
match self {
Self::Proceed => Some(Self::Skip),
Self::Skip => Some(Self::SkipForever),
Self::SkipForever => None,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MigrationView {
Summary,
Customize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -136,6 +117,8 @@ pub(crate) async fn run_external_agent_config_migration_prompt(
struct ExternalAgentConfigMigrationScreen {
request_frame: FrameRequester,
items: Vec<MigrationSelection>,
groups: Vec<ExternalAgentConfigMigrationGroupModel>,
view: MigrationView,
selected_item_idx: Option<usize>,
scroll_top: usize,
focus: FocusArea,
@@ -151,44 +134,67 @@ impl ExternalAgentConfigMigrationScreen {
}
fn first_available_action(&self) -> ActionMenuOption {
if self.proceed_enabled() {
ActionMenuOption::Proceed
} else {
ActionMenuOption::Skip
match self.available_actions().first() {
Some(action) => *action,
None => ActionMenuOption::Back,
}
}
fn last_available_action(&self) -> ActionMenuOption {
match self.available_actions().last() {
Some(action) => *action,
None => ActionMenuOption::Back,
}
}
fn previous_available_action(&self, action: ActionMenuOption) -> Option<ActionMenuOption> {
let mut candidate = action.previous();
while let Some(option) = candidate {
if option != ActionMenuOption::Proceed || self.proceed_enabled() {
return Some(option);
}
candidate = option.previous();
}
None
let actions = self.available_actions();
actions
.iter()
.position(|candidate| *candidate == action)
.and_then(|idx| idx.checked_sub(1))
.and_then(|idx| actions.get(idx))
.copied()
}
fn next_available_action(&self, action: ActionMenuOption) -> Option<ActionMenuOption> {
let mut candidate = action.next();
while let Some(option) = candidate {
if option != ActionMenuOption::Proceed || self.proceed_enabled() {
return Some(option);
let actions = self.available_actions();
actions
.iter()
.position(|candidate| *candidate == action)
.and_then(|idx| actions.get(idx + 1))
.copied()
}
fn available_actions(&self) -> Vec<ActionMenuOption> {
match self.view {
MigrationView::Summary => {
let mut actions = Vec::new();
if self.proceed_enabled() {
actions.push(ActionMenuOption::Proceed);
}
actions.extend([ActionMenuOption::Customize, ActionMenuOption::Skip]);
actions
}
candidate = option.next();
MigrationView::Customize => vec![ActionMenuOption::Back],
}
None
}
fn normalize_highlighted_action(&mut self) {
if self.highlighted_action == ActionMenuOption::Proceed && !self.proceed_enabled() {
if !self.available_actions().contains(&self.highlighted_action) {
self.highlighted_action = self.first_available_action();
}
}
fn display_description(item: &ExternalAgentConfigMigrationItem) -> String {
// App-server descriptions use migration vocabulary. Normalize that prefix so the TUI
// consistently uses the user-facing import vocabulary.
let description = item
.description
.strip_prefix("Migrate ")
.map_or_else(|| item.description.clone(), |rest| format!("Import {rest}"));
let Some(cwd) = item.cwd.as_deref() else {
return item.description.clone();
return description;
};
fn reformat_description(
@@ -207,33 +213,23 @@ impl ExternalAgentConfigMigrationScreen {
))
}
if let Some(reformatted) =
reformat_description(&item.description, "Migrate ", " into ", cwd)
{
if let Some(reformatted) = reformat_description(&description, "Import ", " into ", cwd) {
return reformatted;
}
if let Some(reformatted) =
reformat_description(&item.description, "Migrate skills from ", " to ", cwd)
reformat_description(&description, "Import skills from ", " to ", cwd)
{
return reformatted;
}
if let Some(reformatted) = reformat_description(&item.description, "Migrate ", " to ", cwd)
{
if let Some(reformatted) = reformat_description(&description, "Import ", " to ", cwd) {
return reformatted;
}
if let Some(reformatted) = reformat_description(&item.description, "Import ", " to ", cwd) {
return reformatted;
}
if let Some(source) = item
.description
.strip_prefix("Migrate enabled plugins from ")
{
if let Some(source) = description.strip_prefix("Import enabled plugins from ") {
let description = format!(
"Migrate enabled plugins from {}",
"Import enabled plugins from {}",
display_path_for(std::path::Path::new(source), cwd)
);
if let Some(details) = &item.details {
@@ -260,7 +256,7 @@ impl ExternalAgentConfigMigrationScreen {
return description;
}
item.description.clone()
description
}
fn new(
@@ -269,6 +265,7 @@ impl ExternalAgentConfigMigrationScreen {
selected_items: &[ExternalAgentConfigMigrationItem],
error: Option<String>,
) -> Self {
let groups = external_agent_config_migration_groups(items);
let items = items
.iter()
.cloned()
@@ -277,13 +274,15 @@ impl ExternalAgentConfigMigrationScreen {
item,
})
.collect::<Vec<_>>();
let selected_item_idx = (!items.is_empty()).then_some(0);
let selected_item_idx = (!groups.is_empty()).then_some(0);
Self {
request_frame,
items,
groups,
view: MigrationView::Summary,
selected_item_idx,
scroll_top: 0,
focus: FocusArea::Items,
focus: FocusArea::Actions,
highlighted_action: ActionMenuOption::Proceed,
done: false,
outcome: ExternalAgentConfigMigrationOutcome::Skip,
@@ -341,12 +340,6 @@ impl ExternalAgentConfigMigrationScreen {
fn proceed(&mut self) {
let selected = self.selected_items();
if selected.is_empty() {
self.error = Some("Select at least one item or choose a skip option.".to_string());
self.request_frame.schedule_frame();
return;
}
self.finish_with(ExternalAgentConfigMigrationOutcome::Proceed(selected));
}
@@ -354,14 +347,6 @@ impl ExternalAgentConfigMigrationScreen {
self.finish_with(ExternalAgentConfigMigrationOutcome::Skip);
}
fn skip_forever(&mut self) {
self.finish_with(ExternalAgentConfigMigrationOutcome::SkipForever);
}
fn exit(&mut self) {
self.finish_with(ExternalAgentConfigMigrationOutcome::Exit);
}
fn selected_items(&self) -> Vec<ExternalAgentConfigMigrationItem> {
self.items
.iter()
@@ -374,6 +359,22 @@ impl ExternalAgentConfigMigrationScreen {
self.items.iter().filter(|item| item.enabled).count()
}
fn group_selection_marker(
&self,
group: &ExternalAgentConfigMigrationGroupModel,
) -> &'static str {
let enabled_count = group
.item_indices
.iter()
.filter(|idx| self.items[**idx].enabled)
.count();
match enabled_count {
0 => " ",
count if count == group.item_indices.len() => "x",
_ => "-",
}
}
fn set_all_enabled(&mut self, enabled: bool) {
for item in &mut self.items {
item.enabled = enabled;
@@ -384,7 +385,7 @@ impl ExternalAgentConfigMigrationScreen {
}
fn toggle_selected_item(&mut self) {
if self.focus != FocusArea::Items {
if self.view != MigrationView::Customize || self.focus != FocusArea::Items {
return;
}
let Some(selected_idx) = self.selected_item_idx else {
@@ -393,26 +394,51 @@ impl ExternalAgentConfigMigrationScreen {
let Some(item) = self.items.get_mut(selected_idx) else {
return;
};
item.enabled = !item.enabled;
self.error = None;
self.normalize_highlighted_action();
self.request_frame.schedule_frame();
}
fn customize(&mut self) {
self.view = MigrationView::Customize;
self.selected_item_idx = (!self.items.is_empty()).then_some(0);
self.scroll_top = 0;
self.focus = FocusArea::Items;
self.highlighted_action = ActionMenuOption::Back;
self.request_frame.schedule_frame();
}
fn back_to_summary(&mut self) {
self.view = MigrationView::Summary;
self.selected_item_idx = (!self.groups.is_empty()).then_some(0);
self.scroll_top = 0;
self.focus = FocusArea::Actions;
self.highlighted_action = self.first_available_action();
self.request_frame.schedule_frame();
}
fn move_up(&mut self) {
if self.view == MigrationView::Summary {
self.focus = FocusArea::Actions;
self.highlighted_action = self
.previous_available_action(self.highlighted_action)
.unwrap_or_else(|| self.last_available_action());
self.request_frame.schedule_frame();
return;
}
match self.focus {
FocusArea::Items => match self.selected_item_idx {
Some(0) => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::SkipForever;
self.highlighted_action = self.last_available_action();
}
Some(idx) => {
self.selected_item_idx = Some(idx.saturating_sub(1));
}
None => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::SkipForever;
self.highlighted_action = self.last_available_action();
}
},
FocusArea::Actions => {
@@ -431,6 +457,14 @@ impl ExternalAgentConfigMigrationScreen {
}
fn move_down(&mut self) {
if self.view == MigrationView::Summary {
self.focus = FocusArea::Actions;
self.highlighted_action = self
.next_available_action(self.highlighted_action)
.unwrap_or_else(|| self.first_available_action());
self.request_frame.schedule_frame();
return;
}
match self.focus {
FocusArea::Items => match self.selected_item_idx {
Some(idx) if idx + 1 < self.items.len() => {
@@ -461,8 +495,9 @@ impl ExternalAgentConfigMigrationScreen {
FocusArea::Items => self.toggle_selected_item(),
FocusArea::Actions => match self.highlighted_action {
ActionMenuOption::Proceed => self.proceed(),
ActionMenuOption::Customize => self.customize(),
ActionMenuOption::Skip => self.skip(),
ActionMenuOption::SkipForever => self.skip_forever(),
ActionMenuOption::Back => self.back_to_summary(),
},
}
}
@@ -471,39 +506,47 @@ impl ExternalAgentConfigMigrationScreen {
if key_event.kind == KeyEventKind::Release {
return;
}
if is_ctrl_exit_combo(key_event) {
self.exit();
self.skip();
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => self.move_up(),
KeyCode::Down | KeyCode::Char('j') => self.move_down(),
KeyCode::Char('1') => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::Proceed;
self.proceed();
KeyCode::Char(number @ '1'..='9') => self.select_numbered_action(number),
KeyCode::Char('c') if self.view == MigrationView::Summary => self.customize(),
KeyCode::Char('b') if self.view == MigrationView::Customize => self.back_to_summary(),
KeyCode::Char(' ') if self.view == MigrationView::Customize => {
self.toggle_selected_item();
}
KeyCode::Char('2') => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::Skip;
self.skip();
KeyCode::Char('a') if self.view == MigrationView::Customize => {
self.set_all_enabled(/*enabled*/ true);
}
KeyCode::Char('3') => {
self.focus = FocusArea::Actions;
self.highlighted_action = ActionMenuOption::SkipForever;
self.skip_forever();
KeyCode::Char('n') if self.view == MigrationView::Customize => {
self.set_all_enabled(/*enabled*/ false);
}
KeyCode::Char(' ') => self.toggle_selected_item(),
KeyCode::Char('a') => self.set_all_enabled(/*enabled*/ true),
KeyCode::Char('n') => self.set_all_enabled(/*enabled*/ false),
KeyCode::Enter => self.confirm_selection(),
KeyCode::Esc => self.skip(),
KeyCode::Esc => match self.view {
MigrationView::Summary => self.skip(),
MigrationView::Customize => self.back_to_summary(),
},
_ => {}
}
}
fn select_numbered_action(&mut self, number: char) {
let Some(index) = number.to_digit(10).and_then(|number| number.checked_sub(1)) else {
return;
};
let Some(action) = self.available_actions().get(index as usize).copied() else {
return;
};
self.focus = FocusArea::Actions;
self.highlighted_action = action;
self.confirm_selection();
}
fn ensure_selected_item_visible(&mut self) {
let Some(selected_idx) = self.selected_item_idx else {
self.scroll_top = 0;
@@ -534,12 +577,47 @@ impl ExternalAgentConfigMigrationScreen {
fn section_title(cwd: Option<&std::path::Path>) -> Line<'static> {
match cwd {
Some(cwd) => Line::from(vec!["Project: ".bold(), cwd.display().to_string().dim()]),
Some(cwd) => Line::from(vec![
"Current project: ".bold(),
cwd.display().to_string().dim(),
]),
None => Line::from("Home".bold()),
}
}
fn build_render_lines(&self) -> Vec<RenderLineEntry> {
match self.view {
MigrationView::Summary => self.build_summary_render_lines(),
MigrationView::Customize => self.build_customize_render_lines(),
}
}
fn build_summary_render_lines(&self) -> Vec<RenderLineEntry> {
self.groups
.iter()
.enumerate()
.flat_map(|(idx, group)| {
[
RenderLineEntry {
item_idx: Some(idx),
kind: RenderLineKind::Item,
line: Line::from(format!(
" [{}] {}",
self.group_selection_marker(group),
group.label
)),
},
RenderLineEntry {
item_idx: None,
kind: RenderLineKind::ItemDetail,
line: Line::from(format!(" {}", group.description)),
},
]
})
.collect()
}
fn build_customize_render_lines(&self) -> Vec<RenderLineEntry> {
let mut lines = Vec::new();
let mut current_scope: Option<Option<&std::path::Path>> = None;
for (idx, item) in self.items.iter().enumerate() {
@@ -565,9 +643,21 @@ impl ExternalAgentConfigMigrationScreen {
line: Line::from(format!(
" [{}] {}",
if item.enabled { "x" } else { " " },
Self::display_description(&item.item)
external_agent_config_migration_item_label(&item.item)
)),
});
lines.push(RenderLineEntry {
item_idx: None,
kind: RenderLineKind::ItemDetail,
line: Line::from(format!(" {}", Self::display_description(&item.item))),
});
if let Some(details) = external_agent_config_migration_item_detail(&item.item) {
lines.push(RenderLineEntry {
item_idx: None,
kind: RenderLineKind::ItemDetail,
line: Line::from(format!(" {details}")),
});
}
if let Some(details) = &item.item.details {
for line in Self::plugin_detail_lines(&details.plugins) {
lines.push(RenderLineEntry {
@@ -580,194 +670,25 @@ impl ExternalAgentConfigMigrationScreen {
}
lines
}
fn render_items(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let rows = self.build_render_lines();
let visible_rows = area.height as usize;
let mut start_idx = self.scroll_top.min(rows.len().saturating_sub(1));
if let Some(selected_item_idx) = self.selected_item_idx {
let selected_render_idx = self.selected_render_line_index(selected_item_idx);
if selected_render_idx < start_idx {
start_idx = selected_render_idx;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if selected_render_idx > bottom {
start_idx = selected_render_idx + 1 - visible_rows;
}
}
}
let mut y = area.y;
for entry in rows.iter().skip(start_idx).take(visible_rows) {
if y >= area.y + area.height {
break;
}
let selected =
self.focus == FocusArea::Items && self.selected_item_idx == entry.item_idx;
let mut line = entry.line.clone();
if selected {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.patch(accent_style());
});
} else if entry.kind != RenderLineKind::Item && !line.spans.is_empty() {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
let line = truncate_line_with_ellipsis_if_overflow(line, area.width as usize);
line.render(
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
buf,
);
y = y.saturating_add(1);
}
}
}
impl WidgetRef for &ExternalAgentConfigMigrationScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let inner_area = area.inset(Insets::vh(/*v*/ 1, /*h*/ 2));
let error_height = u16::from(self.error.is_some());
let fixed_height = 1u16 + 2u16 + error_height + 1u16 + 4u16 + 1u16;
let list_height =
self.render_line_count()
.max(1)
.min(inner_area.height.saturating_sub(fixed_height) as usize) as u16;
let [
header_area,
intro_area,
error_area,
list_area,
list_gap_area,
actions_area,
footer_area,
_spacer_area,
] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(2),
Constraint::Length(error_height),
Constraint::Length(list_height),
Constraint::Length(1),
Constraint::Length(4),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(inner_area);
let heading = Line::from(vec!["> ".into(), "External agent config detected".bold()]);
heading.render(header_area, buf);
Paragraph::new(vec![
Line::from("We found settings from another agent that you can add to this project."),
Line::from("Select what to import"),
])
.wrap(Wrap { trim: false })
.render(intro_area, buf);
if let Some(error) = &self.error {
Paragraph::new(error.clone().red().to_string())
.wrap(Wrap { trim: false })
.render(error_area, buf);
}
self.render_items(list_area, buf);
Clear.render(list_gap_area, buf);
let [
actions_intro_area,
proceed_area,
skip_area,
skip_forever_area,
] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(actions_area);
let actions_intro = format!(
"Selected {} of {} item(s).",
self.selected_count(),
self.items.len()
);
Paragraph::new(actions_intro)
.wrap(Wrap { trim: false })
.render(actions_intro_area, buf);
selection_option_row_with_dim(
/*index*/ 0,
ActionMenuOption::Proceed.label().to_string(),
self.focus == FocusArea::Actions
&& self.highlighted_action == ActionMenuOption::Proceed,
/*dim*/ self.focus != FocusArea::Actions || !self.proceed_enabled(),
)
.render(proceed_area, buf);
selection_option_row_with_dim(
/*index*/ 1,
ActionMenuOption::Skip.label().to_string(),
self.focus == FocusArea::Actions && self.highlighted_action == ActionMenuOption::Skip,
/*dim*/ self.focus != FocusArea::Actions,
)
.render(skip_area, buf);
selection_option_row_with_dim(
/*index*/ 2,
ActionMenuOption::SkipForever.label().to_string(),
self.focus == FocusArea::Actions
&& self.highlighted_action == ActionMenuOption::SkipForever,
/*dim*/ self.focus != FocusArea::Actions,
)
.render(skip_forever_area, buf);
Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, ".dim(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle, ".dim(),
"1".cyan(),
"/".dim(),
"2".cyan(),
"/".dim(),
"3".cyan(),
" to choose, ".dim(),
"a".cyan(),
"/".dim(),
"n".cyan(),
" for all/none".dim(),
])
.render(footer_area, buf);
}
}
fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool {
key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
matches!(key_event.code, KeyCode::Char('c' | 'd'))
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
}
#[cfg(test)]
mod tests {
use super::ActionMenuOption;
use super::ExternalAgentConfigMigrationOutcome;
use super::ExternalAgentConfigMigrationScreen;
use super::FocusArea;
use super::MigrationView;
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::PluginsMigration;
use codex_app_server_protocol::SessionMigration;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@@ -829,6 +750,19 @@ mod tests {
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Sessions,
description: "Migrate recent Claude Code sessions".to_string(),
cwd: None,
details: Some(codex_app_server_protocol::MigrationDetails {
sessions: vec![SessionMigration {
path: PathBuf::from("/Users/alex/.claude/projects/project/session.jsonl"),
cwd: project_root.clone(),
title: Some("Investigate migration UX".to_string()),
}],
..Default::default()
}),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
@@ -864,7 +798,13 @@ mod tests {
frame.render_widget_ref(screen, frame.area());
}
terminal.flush().expect("flush");
terminal.backend().to_string()
terminal
.backend()
.to_string()
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
}
#[test]
@@ -877,13 +817,34 @@ mod tests {
/*error*/ None,
);
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 21);
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 24);
#[cfg(windows)]
assert_snapshot!("external_agent_config_migration_prompt_windows", rendered);
#[cfg(not(windows))]
assert_snapshot!("external_agent_config_migration_prompt", rendered);
}
#[test]
fn customize_snapshot() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.customize();
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 30);
#[cfg(windows)]
assert_snapshot!(
"external_agent_config_migration_customize_windows",
rendered
);
#[cfg(not(windows))]
assert_snapshot!("external_agent_config_migration_customize", rendered);
}
#[test]
fn proceed_returns_selected_items() {
let items = sample_items();
@@ -894,9 +855,6 @@ mod tests {
/*error*/ None,
);
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(screen.is_done());
@@ -916,16 +874,19 @@ mod tests {
/*error*/ None,
);
screen.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(screen.is_done());
assert_eq!(
screen.outcome(),
ExternalAgentConfigMigrationOutcome::Proceed(vec![items[1].clone(), items[2].clone(),])
ExternalAgentConfigMigrationOutcome::Proceed(vec![
items[1].clone(),
items[2].clone(),
items[3].clone(),
])
);
}
@@ -946,30 +907,7 @@ mod tests {
}
#[test]
fn skip_forever_returns_skip_forever_outcome() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.move_down();
screen.move_down();
screen.move_down();
screen.move_down();
screen.move_down();
screen.confirm_selection();
assert_eq!(
screen.outcome(),
ExternalAgentConfigMigrationOutcome::SkipForever
);
}
#[test]
fn proceed_requires_at_least_one_selected_item() {
fn numeric_shortcuts_follow_visible_actions_when_proceed_is_disabled() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
@@ -978,35 +916,30 @@ mod tests {
/*error*/ None,
);
screen.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert!(!screen.is_done());
assert_eq!(screen.highlighted_action, ActionMenuOption::Proceed);
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 20);
assert!(
rendered.contains("Select at least one item or choose a skip option."),
"expected inline validation error, got:\n{rendered}"
);
assert_eq!(screen.view, MigrationView::Customize);
}
#[test]
fn proceed_action_is_skipped_when_no_items_are_selected() {
fn control_exit_shortcuts_cancel_prompt() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
for key_code in [KeyCode::Char('c'), KeyCode::Char('d')] {
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
screen.handle_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
screen.handle_key(KeyEvent::new(key_code, KeyModifiers::CONTROL));
assert_eq!(screen.focus, FocusArea::Actions);
assert_eq!(screen.highlighted_action, ActionMenuOption::Skip);
assert!(screen.is_done());
assert_eq!(screen.outcome(), ExternalAgentConfigMigrationOutcome::Skip);
}
}
#[test]
@@ -1025,28 +958,42 @@ mod tests {
ExternalAgentConfigMigrationOutcome::Proceed(items.clone())
);
let mut customize_screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
customize_screen.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(customize_screen.view, MigrationView::Customize);
customize_screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert_eq!(customize_screen.view, MigrationView::Summary);
let mut skip_screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
skip_screen.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
skip_screen.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
assert_eq!(
skip_screen.outcome(),
ExternalAgentConfigMigrationOutcome::Skip
);
}
let mut skip_forever_screen = ExternalAgentConfigMigrationScreen::new(
#[test]
fn summary_does_not_toggle_selection() {
let items = sample_items();
let mut screen = ExternalAgentConfigMigrationScreen::new(
FrameRequester::test_dummy(),
&items,
&items,
/*error*/ None,
);
skip_forever_screen.handle_key(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
assert_eq!(
skip_forever_screen.outcome(),
ExternalAgentConfigMigrationOutcome::SkipForever
);
screen.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(screen.selected_items(), items);
}
}
@@ -0,0 +1,192 @@
use super::*;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::selection_list::selection_option_row_with_dim;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
impl ExternalAgentConfigMigrationScreen {
fn render_items(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
let rows = self.build_render_lines();
let visible_rows = area.height as usize;
let mut start_idx = self.scroll_top.min(rows.len().saturating_sub(1));
if let Some(selected_item_idx) = self.selected_item_idx {
let selected_render_idx = self.selected_render_line_index(selected_item_idx);
if selected_render_idx < start_idx {
start_idx = selected_render_idx;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if selected_render_idx > bottom {
start_idx = selected_render_idx + 1 - visible_rows;
}
}
}
let mut y = area.y;
for entry in rows.iter().skip(start_idx).take(visible_rows) {
if y >= area.y + area.height {
break;
}
let selected =
self.focus == FocusArea::Items && self.selected_item_idx == entry.item_idx;
let mut line = entry.line.clone();
if selected {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.cyan().bold();
});
} else if entry.kind != RenderLineKind::Item && !line.spans.is_empty() {
line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
let line = truncate_line_with_ellipsis_if_overflow(line, area.width as usize);
line.render(
Rect {
x: area.x,
y,
width: area.width,
height: 1,
},
buf,
);
y = y.saturating_add(1);
}
}
}
impl WidgetRef for &ExternalAgentConfigMigrationScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let inner_area = area.inset(Insets::vh(/*v*/ 1, /*h*/ 2));
let error_height = u16::from(self.error.is_some());
let intro_lines = match self.view {
MigrationView::Summary => vec![
Line::from("Bring over your setup, current project, and recent chats."),
Line::from("Codex may add files to your current project folder."),
Line::from("Your existing Claude Code setup will not be changed."),
Line::from("Standard Claude Chat data cannot be imported."),
],
MigrationView::Customize => vec![
Line::from("Choose the Claude Code items to import."),
Line::from("Codex may add files to your current project folder."),
Line::from("Your existing Claude Code setup will not be changed."),
],
};
let intro_height = intro_lines.len() as u16;
let actions = self.available_actions();
let actions_height = actions.len() as u16 + 1;
let fixed_height = 1u16 + intro_height + error_height + 1u16 + actions_height + 1u16;
let list_height =
self.render_line_count()
.max(1)
.min(inner_area.height.saturating_sub(fixed_height) as usize) as u16;
let [
header_area,
intro_area,
error_area,
list_area,
list_gap_area,
actions_area,
footer_area,
_spacer_area,
] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(intro_height),
Constraint::Length(error_height),
Constraint::Length(list_height),
Constraint::Length(1),
Constraint::Length(actions_height),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(inner_area);
let title = match self.view {
MigrationView::Summary => "Import from Claude Code",
MigrationView::Customize => "Choose what to import",
};
let heading = Line::from(vec!["> ".into(), title.bold()]);
heading.render(header_area, buf);
Paragraph::new(intro_lines)
.wrap(Wrap { trim: false })
.render(intro_area, buf);
if let Some(error) = &self.error {
Paragraph::new(error.clone().red().to_string())
.wrap(Wrap { trim: false })
.render(error_area, buf);
}
self.render_items(list_area, buf);
Clear.render(list_gap_area, buf);
let action_areas = Layout::vertical(std::iter::repeat_n(
Constraint::Length(1),
actions.len() + 1,
))
.split(actions_area);
let item_label = if self.items.len() == 1 {
"item"
} else {
"items"
};
let actions_intro = format!(
"Selected {} of {} {item_label}.",
self.selected_count(),
self.items.len()
);
Paragraph::new(actions_intro)
.wrap(Wrap { trim: false })
.render(action_areas[0], buf);
for (idx, action) in actions.iter().enumerate() {
selection_option_row_with_dim(
idx,
action.label().to_string(),
self.focus == FocusArea::Actions && self.highlighted_action == *action,
/*dim*/ self.focus != FocusArea::Actions,
)
.render(action_areas[idx + 1], buf);
}
let footer = match self.view {
MigrationView::Summary => Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to select, ".dim(),
"c".cyan(),
" to customize".dim(),
]),
MigrationView::Customize => Line::from(vec![
"Use ".dim(),
key_hint::plain(KeyCode::Up).into(),
"/".dim(),
key_hint::plain(KeyCode::Down).into(),
" to move, ".dim(),
key_hint::plain(KeyCode::Char(' ')).into(),
" to toggle, ".dim(),
"b".cyan(),
" to go back".dim(),
]),
};
footer.render(footer_area, buf);
}
}
@@ -0,0 +1,124 @@
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::legacy_core::config::Config;
use crate::tui;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
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),
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 remaining_items_handoff(remaining_item_count: usize) -> Option<String> {
match remaining_item_count {
0 => None,
1 => Some(
"1 additional item remains. After it finishes, run /import again to review it."
.to_string(),
),
_ => Some(format!(
"{remaining_item_count} additional items remain. After it finishes, run /import again to review them."
)),
}
}
pub(crate) async fn handle_external_agent_config_migration_prompt(
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
config: &Config,
) -> Result<ExternalAgentConfigMigrationFlowOutcome, String> {
if app_server.uses_remote_workspace() {
return Err(EXTERNAL_AGENT_CONFIG_MIGRATION_REMOTE_UNAVAILABLE_MESSAGE.to_string());
}
if !app_server.uses_embedded_app_server() {
return Err(EXTERNAL_AGENT_CONFIG_MIGRATION_DAEMON_UNAVAILABLE_MESSAGE.to_string());
}
if app_server.external_agent_config_import_in_progress() {
return Err(EXTERNAL_AGENT_CONFIG_IMPORT_IN_PROGRESS_MESSAGE.to_string());
}
let cwd = config.cwd.to_path_buf();
let detected_items = match app_server
.external_agent_config_detect(ExternalAgentConfigDetectParams {
include_home: true,
cwds: Some(vec![cwd.clone()]),
})
.await
{
Ok(response) => response.items,
Err(err) => {
tracing::warn!(
error = %err,
cwd = %cwd.display(),
"failed to detect external agent config migrations"
);
return Err(format!("Could not check for Claude Code setup: {err}"));
}
};
if detected_items.is_empty() {
return Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems);
}
let mut selected_items = detected_items.clone();
let mut error: Option<String> = None;
loop {
match run_external_agent_config_migration_prompt(
tui,
&detected_items,
&selected_items,
error.as_deref(),
)
.await
{
ExternalAgentConfigMigrationOutcome::Proceed(items) => {
selected_items = items.clone();
match app_server.external_agent_config_import(items).await {
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);
return Ok(ExternalAgentConfigMigrationFlowOutcome::Started(
success_message,
));
}
Err(err) => {
tracing::warn!(
error = %err,
cwd = %cwd.display(),
"failed to import external agent config migration items"
);
error = Some(format!("Import failed: {err}"));
}
}
}
ExternalAgentConfigMigrationOutcome::Skip => {
return Ok(ExternalAgentConfigMigrationFlowOutcome::Cancelled);
}
}
}
}
#[cfg(test)]
#[path = "external_agent_config_migration_flow_tests.rs"]
mod tests;
@@ -0,0 +1,21 @@
use super::*;
#[test]
fn external_agent_config_migration_messages_snapshot() {
let cases = [0, 1, 2];
let messages = cases
.map(external_agent_config_migration_success_message)
.into_iter()
.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(),
])
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("external_agent_config_migration_messages", messages);
}
@@ -0,0 +1,147 @@
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(", ")),
}
}
@@ -1,567 +0,0 @@
use crate::app_server_session::AppServerSession;
use crate::external_agent_config_migration::ExternalAgentConfigMigrationOutcome;
use crate::external_agent_config_migration::run_external_agent_config_migration_prompt;
use crate::legacy_core::config::Config;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
use crate::tui;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_features::Feature;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::collections::BTreeSet;
use std::path::Path;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use toml::Value as TomlValue;
const EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS: i64 = 5 * 24 * 60 * 60;
pub(crate) enum ExternalAgentConfigMigrationStartupOutcome {
Continue { success_message: Option<String> },
ExitRequested,
}
pub(crate) fn should_show_external_agent_config_migration_prompt(
config: &Config,
entered_trust_nux: bool,
) -> bool {
entered_trust_nux && config.features.enabled(Feature::ExternalMigration)
}
fn external_config_migration_project_key(path: &Path) -> String {
path.display().to_string()
}
fn is_external_config_migration_scope_hidden(config: &Config, cwd: Option<&Path>) -> bool {
match cwd {
Some(cwd) => config
.notices
.external_config_migration_prompts
.projects
.get(&external_config_migration_project_key(cwd))
.copied()
.unwrap_or(false),
None => config
.notices
.external_config_migration_prompts
.home
.unwrap_or(false),
}
}
fn external_config_migration_last_prompted_at(config: &Config, cwd: Option<&Path>) -> Option<i64> {
match cwd {
Some(cwd) => config
.notices
.external_config_migration_prompts
.project_last_prompted_at
.get(&external_config_migration_project_key(cwd))
.copied(),
None => {
config
.notices
.external_config_migration_prompts
.home_last_prompted_at
}
}
}
fn is_external_config_migration_scope_cooling_down(
config: &Config,
cwd: Option<&Path>,
now_unix_seconds: i64,
) -> bool {
external_config_migration_last_prompted_at(config, cwd).is_some_and(|last_prompted_at| {
last_prompted_at.saturating_add(EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS)
> now_unix_seconds
})
}
fn visible_external_agent_config_migration_items(
config: &Config,
items: Vec<ExternalAgentConfigMigrationItem>,
now_unix_seconds: i64,
) -> Vec<ExternalAgentConfigMigrationItem> {
items
.into_iter()
.filter(|item| {
!is_external_config_migration_scope_hidden(config, item.cwd.as_deref())
&& !is_external_config_migration_scope_cooling_down(
config,
item.cwd.as_deref(),
now_unix_seconds,
)
})
.collect()
}
fn external_agent_config_migration_success_message(
items: &[ExternalAgentConfigMigrationItem],
) -> String {
if items.iter().any(|item| {
item.item_type == codex_app_server_protocol::ExternalAgentConfigMigrationItemType::Plugins
}) {
"External config migration completed. Plugin migration is still in progress and may take a few minutes."
.to_string()
} else {
"External config migration completed successfully.".to_string()
}
}
fn unix_seconds_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
async fn persist_external_agent_config_migration_prompt_shown(
config: &mut Config,
items: &[ExternalAgentConfigMigrationItem],
now_unix_seconds: i64,
) -> Result<()> {
let mut edits = Vec::new();
if items.iter().any(|item| item.cwd.is_none()) {
edits.push(
ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(now_unix_seconds),
);
}
for project in items
.iter()
.filter_map(|item| item.cwd.as_deref())
.map(external_config_migration_project_key)
{
edits.push(
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
project,
now_unix_seconds,
),
);
}
if edits.is_empty() {
return Ok(());
}
ConfigEditsBuilder::for_config(config)
.with_edits(edits)
.apply()
.await
.map_err(|err| color_eyre::eyre::eyre!("{err}"))
.wrap_err("Failed to save external config migration prompt timestamp")?;
if items.iter().any(|item| item.cwd.is_none()) {
config
.notices
.external_config_migration_prompts
.home_last_prompted_at = Some(now_unix_seconds);
}
for project in items
.iter()
.filter_map(|item| item.cwd.as_deref())
.map(external_config_migration_project_key)
{
config
.notices
.external_config_migration_prompts
.project_last_prompted_at
.insert(project, now_unix_seconds);
}
Ok(())
}
async fn persist_external_agent_config_migration_prompt_dismissal(
config: &mut Config,
items: &[ExternalAgentConfigMigrationItem],
) -> Result<()> {
let hide_home = items.iter().any(|item| item.cwd.is_none());
let projects = items
.iter()
.filter_map(|item| item.cwd.as_deref())
.map(external_config_migration_project_key)
.collect::<BTreeSet<_>>();
let mut edits = Vec::new();
if hide_home
&& !config
.notices
.external_config_migration_prompts
.home
.unwrap_or(false)
{
edits.push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
true,
));
}
for project in &projects {
if !config
.notices
.external_config_migration_prompts
.projects
.get(project)
.copied()
.unwrap_or(false)
{
edits.push(
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
project.clone(),
true,
),
);
}
}
if edits.is_empty() {
return Ok(());
}
ConfigEditsBuilder::for_config(config)
.with_edits(edits)
.apply()
.await
.map_err(|err| color_eyre::eyre::eyre!("{err}"))
.wrap_err("Failed to save external config migration prompt preference")?;
if hide_home {
config.notices.external_config_migration_prompts.home = Some(true);
}
for project in projects {
config
.notices
.external_config_migration_prompts
.projects
.insert(project, true);
}
Ok(())
}
pub(crate) async fn handle_external_agent_config_migration_prompt_if_needed(
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
config: &mut Config,
cli_kv_overrides: &[(String, TomlValue)],
harness_overrides: &ConfigOverrides,
entered_trust_nux: bool,
) -> Result<ExternalAgentConfigMigrationStartupOutcome> {
if !should_show_external_agent_config_migration_prompt(config, entered_trust_nux) {
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
let now_unix_seconds = unix_seconds_now();
let detected_items = match app_server
.external_agent_config_detect(ExternalAgentConfigDetectParams {
include_home: true,
cwds: Some(vec![config.cwd.to_path_buf()]),
})
.await
{
Ok(response) => {
visible_external_agent_config_migration_items(config, response.items, now_unix_seconds)
}
Err(err) => {
tracing::warn!(
error = %err,
cwd = %config.cwd.display(),
"failed to detect external agent config migrations; continuing startup"
);
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
};
if detected_items.is_empty() {
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
if let Err(err) = persist_external_agent_config_migration_prompt_shown(
config,
&detected_items,
now_unix_seconds,
)
.await
{
tracing::warn!(
error = %err,
cwd = %config.cwd.display(),
"failed to persist external config migration prompt timestamp"
);
}
let mut selected_items = detected_items.clone();
let mut error: Option<String> = None;
loop {
match run_external_agent_config_migration_prompt(
tui,
&detected_items,
&selected_items,
error.as_deref(),
)
.await
{
ExternalAgentConfigMigrationOutcome::Proceed(items) => {
selected_items = items.clone();
match app_server.external_agent_config_import(items).await {
Ok(_) => {
let success_message =
external_agent_config_migration_success_message(&selected_items);
*config = ConfigBuilder::default()
.codex_home(config.codex_home.to_path_buf())
.cli_overrides(cli_kv_overrides.to_vec())
.harness_overrides(harness_overrides.clone())
.build()
.await
.wrap_err("Failed to reload config after external agent migration")?;
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: Some(success_message),
});
}
Err(err) => {
tracing::warn!(
error = %err,
cwd = %config.cwd.display(),
"failed to import external agent config migration items"
);
error = Some(format!("Migration failed: {err}"));
}
}
}
ExternalAgentConfigMigrationOutcome::Skip => {
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
ExternalAgentConfigMigrationOutcome::SkipForever => {
match persist_external_agent_config_migration_prompt_dismissal(
config,
&detected_items,
)
.await
{
Ok(()) => {
return Ok(ExternalAgentConfigMigrationStartupOutcome::Continue {
success_message: None,
});
}
Err(err) => {
tracing::warn!(
error = %err,
cwd = %config.cwd.display(),
"failed to persist external config migration prompt dismissal"
);
error = Some(format!("Failed to save preference: {err}"));
}
}
}
ExternalAgentConfigMigrationOutcome::Exit => {
return Ok(ExternalAgentConfigMigrationStartupOutcome::ExitRequested);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::tempdir;
#[tokio::test]
async fn visible_external_agent_config_migration_items_omits_hidden_scopes() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
config.notices.external_config_migration_prompts.home = Some(true);
config
.notices
.external_config_migration_prompts
.projects
.insert("/tmp/project".to_string(), true);
let visible = visible_external_agent_config_migration_items(
&config,
vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: "home".to_string(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: "project".to_string(),
cwd: Some(PathBuf::from("/tmp/project")),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: "other project".to_string(),
cwd: Some(PathBuf::from("/tmp/other")),
details: None,
},
],
/*now_unix_seconds*/ 1_760_000_000,
);
assert_eq!(
visible,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: "other project".to_string(),
cwd: Some(PathBuf::from("/tmp/other")),
details: None,
}]
);
}
#[tokio::test]
async fn visible_external_agent_config_migration_items_omits_recently_prompted_scopes() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
config
.notices
.external_config_migration_prompts
.home_last_prompted_at = Some(1_760_000_000);
config
.notices
.external_config_migration_prompts
.project_last_prompted_at
.insert("/tmp/project".to_string(), 1_760_000_000);
let visible = visible_external_agent_config_migration_items(
&config,
vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: "home".to_string(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: "project".to_string(),
cwd: Some(PathBuf::from("/tmp/project")),
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: "other project".to_string(),
cwd: Some(PathBuf::from("/tmp/other")),
details: None,
},
],
/*now_unix_seconds*/
1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS - 1,
);
assert_eq!(
visible,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: "other project".to_string(),
cwd: Some(PathBuf::from("/tmp/other")),
details: None,
}]
);
}
#[tokio::test]
async fn external_config_migration_scope_cooldown_expires_after_five_days() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
config
.notices
.external_config_migration_prompts
.home_last_prompted_at = Some(1_760_000_000);
assert!(is_external_config_migration_scope_cooling_down(
&config,
/*cwd*/ None,
1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS - 1,
));
assert!(!is_external_config_migration_scope_cooling_down(
&config,
/*cwd*/ None,
1_760_000_000 + EXTERNAL_CONFIG_MIGRATION_PROMPT_COOLDOWN_SECS,
));
}
#[test]
fn external_agent_config_migration_success_message_mentions_plugins_when_present() {
let message = external_agent_config_migration_success_message(&[
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: String::new(),
cwd: None,
details: None,
},
]);
assert_eq!(
message,
"External config migration completed. Plugin migration is still in progress and may take a few minutes."
);
}
#[test]
fn external_agent_config_migration_success_message_omits_plugins_copy_when_absent() {
let message =
external_agent_config_migration_success_message(&[ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: None,
details: None,
}]);
assert_eq!(message, "External config migration completed successfully.");
}
#[tokio::test]
async fn external_agent_config_migration_prompt_requires_trust_nux_entry() {
let codex_home = tempdir().expect("temp codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config");
let _ = config.features.enable(Feature::ExternalMigration);
assert!(!should_show_external_agent_config_migration_prompt(
&config, /*entered_trust_nux*/ false,
));
assert!(should_show_external_agent_config_migration_prompt(
&config, /*entered_trust_nux*/ true,
));
}
}
+7 -19
View File
@@ -131,7 +131,8 @@ mod diff_render;
mod exec_cell;
mod exec_command;
mod external_agent_config_migration;
mod external_agent_config_migration_startup;
mod external_agent_config_migration_flow;
mod external_agent_config_migration_model;
mod external_editor;
mod file_search;
mod frames;
@@ -1810,23 +1811,11 @@ async fn run_ratatui_app(
let hooks_request_handle = app_server.request_handle();
let hooks_cwd = config.cwd.to_path_buf();
let startup_prefetch_started_at = Instant::now();
let should_defer_bootstrap =
external_agent_config_migration_startup::should_show_external_agent_config_migration_prompt(
&config,
should_show_trust_screen_flag,
);
let (startup_bootstrap, startup_hooks_entry) = if should_defer_bootstrap {
(
None,
load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd).await,
)
} else {
let (bootstrap, entry) = tokio::join!(
app_server.bootstrap(&config),
load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd),
);
(Some(bootstrap?), entry)
};
let (startup_bootstrap, startup_hooks_entry) = tokio::join!(
app_server.bootstrap(&config),
load_startup_hooks_review_entry(hooks_request_handle, hooks_cwd),
);
let startup_bootstrap = Some(startup_bootstrap?);
let startup_elapsed_before_app = startup_prefetch_started_at.elapsed();
let startup_hooks_browser = match maybe_run_startup_hooks_review(
&mut app_server,
@@ -1853,7 +1842,6 @@ async fn run_ratatui_app(
session_selection,
feedback,
should_show_trust_screen, // Proxy to: is it a first run in this directory?
should_show_trust_screen_flag, // Preserve the startup-time trust NUX signal before onboarding
should_prompt_windows_sandbox_nux_at_startup,
app_server_target,
state_db,
+3
View File
@@ -26,6 +26,7 @@ pub enum SlashCommand {
AutoReview,
Memories,
Skills,
Import,
Hooks,
Review,
Rename,
@@ -98,6 +99,7 @@ impl SlashCommand {
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Import => "import setup, this project, and recent chats from Claude Code",
SlashCommand::Hooks => "view and manage lifecycle hooks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
@@ -198,6 +200,7 @@ impl SlashCommand {
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Memories
| SlashCommand::Import
| SlashCommand::Review
| SlashCommand::Plan
| SlashCommand::Clear
@@ -0,0 +1,29 @@
---
source: tui/src/external_agent_config_migration.rs
expression: rendered
---
> Choose what to import
Choose the Claude Code items to import.
Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed.
Home
[x] Settings (settings.json -> config.toml)
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
[x] Recent chat sessions
Import recent Claude Code sessions
1 chat session: Investigate migration UX
Current project: /workspace/project
[x] Plugins
Import enabled plugins from .claude/settings.json (4 marketplaces, 6 …
• acme-tools: deployer, formatter, +1 more
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Instructions (CLAUDE.md -> AGENTS.md)
Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items.
1. Done customizing
Use ↑/↓ to move, space to toggle, b to go back
@@ -0,0 +1,29 @@
---
source: tui/src/external_agent_config_migration.rs
expression: rendered
---
> Choose what to import
Choose the Claude Code items to import.
Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed.
Home
[x] Settings (settings.json -> config.toml)
Import /Users/alex/.claude/settings.json into /Users/alex/.codex/conf…
[x] Recent chat sessions
Import recent Claude Code sessions
1 chat session: Investigate migration UX
Current project: C:\workspace\project
[x] Plugins
Import enabled plugins from .claude/settings.json (4 marketplaces, 6 …
• acme-tools: deployer, formatter, +1 more
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Instructions (CLAUDE.md -> AGENTS.md)
Import CLAUDE.md to AGENTS.md
Selected 4 of 4 items.
1. Done customizing
Use ↑/↓ to move, space to toggle, b to go back
@@ -2,22 +2,21 @@
source: tui/src/external_agent_config_migration.rs
expression: rendered
---
> External agent config detected
We found settings from another agent that you can add to this project.
Select what to import
Home
[x] Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/con…
Project: /workspace/project
[x] Migrate enabled plugins from .claude/settings.json (4 marketplaces, 6…
• acme-tools: deployer, formatter, +1 more
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Migrate CLAUDE.md to AGENTS.md
> Import from Claude Code
Bring over your setup, current project, and recent chats.
Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed.
Standard Claude Chat data cannot be imported.
[x] Tools & setup
Settings, instructions, integrations, agents, commands, and skills
[x] Current project
Add Codex files alongside your existing project files
[x] Chat sessions (1)
Last 30 days of chats
Selected 3 of 3 item(s).
1. Proceed with selected
2. Skip for now
3. Don't ask again
Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none
Selected 4 of 4 items.
› 1. Import selected
2. Customize selection
3. Cancel
Use ↑/↓ to move, enter to select, c to customize
@@ -3,22 +3,20 @@ source: tui/src/external_agent_config_migration.rs
expression: rendered
---
> External agent config detected
We found settings from another agent that you can add to this project.
Select what to import
Home
[x] Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/con…
> Import from Claude Code
Bring over your setup, current project, and recent chats.
Codex may add files to your current project folder.
Your existing Claude Code setup will not be changed.
Standard Claude Chat data cannot be imported.
[x] Tools & setup
Settings, instructions, integrations, agents, commands, and skills
[x] Current project
Add Codex files alongside your existing project files
[x] Chat sessions (1)
Last 30 days of chats
Project: C:\workspace\project
[x] Migrate enabled plugins from .claude/settings.json (4 marketplaces, 6…
• acme-tools: deployer, formatter, +1 more
• team-marketplace: asana
• debug: sample
• +1 more marketplaces
[x] Migrate CLAUDE.md to AGENTS.md
Selected 3 of 3 item(s).
1. Proceed with selected
2. Skip for now
3. Don't ask again
Use ↑/↓ to move, space to toggle, 1/2/3 to choose, a/n for all/none
Selected 4 of 4 items.
› 1. Import selected
2. Customize selection
3. Cancel
Use ↑/↓ to move, enter to select, c to customize
@@ -0,0 +1,12 @@
---
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.
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.
A previous Claude Code import is still running. Wait for it to finish before importing again.