mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
WIP: checkpoint CLI import UX integration
This commit is contained in:
@@ -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
|
||||
}));
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
@@ -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
|
||||
+29
@@ -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
|
||||
+16
-17
@@ -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
|
||||
|
||||
+16
-18
@@ -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
|
||||
|
||||
+12
@@ -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.
|
||||
Reference in New Issue
Block a user