mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
cb8b1bbcd6
## Why This PR expands the migration path so Codex can detect and import MCP server config, hooks, commands, and subagents configs in a Codex-native shape. ## What changed - Added a `codex-external-agent-migration` crate that owns conversion logic for external-agent MCP servers, hooks, commands, and subagents. - Extended the app-server external-agent config detection/import API with migration item types for MCP server config, hooks, commands, and subagents. ## Migration strategy The migration is intentionally conservative: Codex only imports external-agent config that can be represented safely in Codex today. Unsupported or ambiguous config is skipped instead of being partially translated into behavior that may not match the source system. - **MCP servers**: import supported stdio and HTTP MCP server definitions into `mcp_servers`. Disabled servers and servers filtered out by source `enabledMcpjsonServers` / `disabledMcpjsonServers` are skipped. Project-scoped MCP entries from `.claude.json` are included when they match the repo path. - **Hooks**: import only supported command hooks into `.codex/hooks.json`. Unsupported hook features such as conditional groups, async handlers, prompt/http hooks, or unknown fields are skipped. Referenced hook scripts are copied into `.codex/hooks/`, preserving any existing target scripts. - **Commands**: import supported external commands as Codex skills under `.agents/skills/source-command-*`. Commands that rely on source runtime expansion such as `$ARGUMENTS`, `$1`, `@file` references, shell interpolation, or colliding generated names are skipped. - **Subagents**: import valid subagent Markdown files into `.codex/agents/*.toml` when they have the minimum Codex agent fields. Source model names are not migrated, so imported agents keep the user’s Codex default model; compatible reasoning effort and sandbox mode are migrated when present. - **Skills and project guidance**: copy missing skill directories into `.agents/skills` and migrate `CLAUDE.md` guidance into `AGENTS.md`, rewriting source-agent terminology to Codex terminology where appropriate. - **Detection details**: detected migration items include lightweight details for UI preview, such as MCP server names, hook event names, generated command skill names, and subagent names. Import still recomputes from disk instead of trusting details as the source of truth. - Adds focused coverage for the new migration behavior and app-server import flow. ## Verification - `cargo test -p codex-external-agent-migration` - `cargo test -p codex-hooks` - `cargo test -p codex-app-server external_agent_config` - `just bazel-lock-check`
1052 lines
34 KiB
Rust
1052 lines
34 KiB
Rust
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::tui::FrameRequester;
|
|
use crate::tui::Tui;
|
|
use crate::tui::TuiEvent;
|
|
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
|
|
use codex_app_server_protocol::PluginsMigration;
|
|
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;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) enum ExternalAgentConfigMigrationOutcome {
|
|
Proceed(Vec<ExternalAgentConfigMigrationItem>),
|
|
Skip,
|
|
SkipForever,
|
|
Exit,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum FocusArea {
|
|
Items,
|
|
Actions,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum ActionMenuOption {
|
|
Proceed,
|
|
Skip,
|
|
SkipForever,
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
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, Debug, PartialEq, Eq)]
|
|
struct MigrationSelection {
|
|
item: ExternalAgentConfigMigrationItem,
|
|
enabled: bool,
|
|
}
|
|
|
|
struct RenderLineEntry {
|
|
item_idx: Option<usize>,
|
|
kind: RenderLineKind,
|
|
line: Line<'static>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum RenderLineKind {
|
|
Section,
|
|
Item,
|
|
ItemDetail,
|
|
}
|
|
|
|
pub(crate) async fn run_external_agent_config_migration_prompt(
|
|
tui: &mut Tui,
|
|
items: &[ExternalAgentConfigMigrationItem],
|
|
selected_items: &[ExternalAgentConfigMigrationItem],
|
|
error: Option<&str>,
|
|
) -> ExternalAgentConfigMigrationOutcome {
|
|
let mut screen = ExternalAgentConfigMigrationScreen::new(
|
|
tui.frame_requester(),
|
|
items,
|
|
selected_items,
|
|
error.map(str::to_owned),
|
|
);
|
|
|
|
let _ = tui.draw(u16::MAX, |frame| {
|
|
frame.render_widget_ref(&screen, frame.area());
|
|
});
|
|
|
|
let events = tui.event_stream();
|
|
tokio::pin!(events);
|
|
|
|
while !screen.is_done() {
|
|
if let Some(event) = events.next().await {
|
|
match event {
|
|
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
|
TuiEvent::Paste(_) => {}
|
|
TuiEvent::Draw | TuiEvent::Resize => {
|
|
let _ = tui.draw(u16::MAX, |frame| {
|
|
frame.render_widget_ref(&screen, frame.area());
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
screen.skip();
|
|
break;
|
|
}
|
|
}
|
|
|
|
screen.outcome()
|
|
}
|
|
|
|
struct ExternalAgentConfigMigrationScreen {
|
|
request_frame: FrameRequester,
|
|
items: Vec<MigrationSelection>,
|
|
selected_item_idx: Option<usize>,
|
|
scroll_top: usize,
|
|
focus: FocusArea,
|
|
highlighted_action: ActionMenuOption,
|
|
done: bool,
|
|
outcome: ExternalAgentConfigMigrationOutcome,
|
|
error: Option<String>,
|
|
}
|
|
|
|
impl ExternalAgentConfigMigrationScreen {
|
|
fn proceed_enabled(&self) -> bool {
|
|
self.selected_count() > 0
|
|
}
|
|
|
|
fn first_available_action(&self) -> ActionMenuOption {
|
|
if self.proceed_enabled() {
|
|
ActionMenuOption::Proceed
|
|
} else {
|
|
ActionMenuOption::Skip
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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);
|
|
}
|
|
candidate = option.next();
|
|
}
|
|
None
|
|
}
|
|
|
|
fn normalize_highlighted_action(&mut self) {
|
|
if self.highlighted_action == ActionMenuOption::Proceed && !self.proceed_enabled() {
|
|
self.highlighted_action = self.first_available_action();
|
|
}
|
|
}
|
|
|
|
fn display_description(item: &ExternalAgentConfigMigrationItem) -> String {
|
|
let Some(cwd) = item.cwd.as_deref() else {
|
|
return item.description.clone();
|
|
};
|
|
|
|
fn reformat_description(
|
|
description: &str,
|
|
prefix: &str,
|
|
separator: &str,
|
|
cwd: &std::path::Path,
|
|
) -> Option<String> {
|
|
let remainder = description.strip_prefix(prefix)?;
|
|
let (left, right) = remainder.split_once(separator)?;
|
|
Some(format!(
|
|
"{prefix}{}{}{}",
|
|
display_path_for(std::path::Path::new(left), cwd),
|
|
separator,
|
|
display_path_for(std::path::Path::new(right), cwd)
|
|
))
|
|
}
|
|
|
|
if let Some(reformatted) =
|
|
reformat_description(&item.description, "Migrate ", " into ", cwd)
|
|
{
|
|
return reformatted;
|
|
}
|
|
|
|
if let Some(reformatted) =
|
|
reformat_description(&item.description, "Migrate skills from ", " to ", cwd)
|
|
{
|
|
return reformatted;
|
|
}
|
|
|
|
if let Some(reformatted) = reformat_description(&item.description, "Migrate ", " 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 ")
|
|
{
|
|
let description = format!(
|
|
"Migrate enabled plugins from {}",
|
|
display_path_for(std::path::Path::new(source), cwd)
|
|
);
|
|
if let Some(details) = &item.details {
|
|
let marketplace_count = details.plugins.len();
|
|
let plugin_count = details
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin_group| plugin_group.plugin_names.len())
|
|
.sum::<usize>();
|
|
return format!(
|
|
"{description} ({marketplace_count} {}, {plugin_count} {})",
|
|
if marketplace_count == 1 {
|
|
"marketplace"
|
|
} else {
|
|
"marketplaces"
|
|
},
|
|
if plugin_count == 1 {
|
|
"plugin"
|
|
} else {
|
|
"plugins"
|
|
}
|
|
);
|
|
}
|
|
return description;
|
|
}
|
|
|
|
item.description.clone()
|
|
}
|
|
|
|
fn new(
|
|
request_frame: FrameRequester,
|
|
items: &[ExternalAgentConfigMigrationItem],
|
|
selected_items: &[ExternalAgentConfigMigrationItem],
|
|
error: Option<String>,
|
|
) -> Self {
|
|
let items = items
|
|
.iter()
|
|
.cloned()
|
|
.map(|item| MigrationSelection {
|
|
enabled: selected_items.contains(&item),
|
|
item,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let selected_item_idx = (!items.is_empty()).then_some(0);
|
|
Self {
|
|
request_frame,
|
|
items,
|
|
selected_item_idx,
|
|
scroll_top: 0,
|
|
focus: FocusArea::Items,
|
|
highlighted_action: ActionMenuOption::Proceed,
|
|
done: false,
|
|
outcome: ExternalAgentConfigMigrationOutcome::Skip,
|
|
error,
|
|
}
|
|
}
|
|
|
|
fn plugin_detail_lines(plugin_groups: &[PluginsMigration]) -> Vec<Line<'static>> {
|
|
let mut lines = plugin_groups
|
|
.iter()
|
|
.take(3)
|
|
.map(|plugin_group| {
|
|
let mut plugin_names = plugin_group
|
|
.plugin_names
|
|
.iter()
|
|
.take(2)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let hidden_plugin_count = plugin_group
|
|
.plugin_names
|
|
.len()
|
|
.saturating_sub(plugin_names.len());
|
|
if hidden_plugin_count > 0 {
|
|
plugin_names.push(format!("+{hidden_plugin_count} more"));
|
|
}
|
|
Line::from(format!(
|
|
" • {}: {}",
|
|
plugin_group.marketplace_name,
|
|
plugin_names.join(", ")
|
|
))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let hidden_marketplace_count = plugin_groups.len().saturating_sub(lines.len());
|
|
if hidden_marketplace_count > 0 {
|
|
lines.push(Line::from(format!(
|
|
" • +{hidden_marketplace_count} more marketplaces"
|
|
)));
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn is_done(&self) -> bool {
|
|
self.done
|
|
}
|
|
|
|
fn outcome(&self) -> ExternalAgentConfigMigrationOutcome {
|
|
self.outcome.clone()
|
|
}
|
|
|
|
fn finish_with(&mut self, outcome: ExternalAgentConfigMigrationOutcome) {
|
|
self.outcome = outcome;
|
|
self.done = true;
|
|
self.request_frame.schedule_frame();
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
fn skip(&mut self) {
|
|
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()
|
|
.filter(|item| item.enabled)
|
|
.map(|item| item.item.clone())
|
|
.collect()
|
|
}
|
|
|
|
fn selected_count(&self) -> usize {
|
|
self.items.iter().filter(|item| item.enabled).count()
|
|
}
|
|
|
|
fn set_all_enabled(&mut self, enabled: bool) {
|
|
for item in &mut self.items {
|
|
item.enabled = enabled;
|
|
}
|
|
self.error = None;
|
|
self.normalize_highlighted_action();
|
|
self.request_frame.schedule_frame();
|
|
}
|
|
|
|
fn toggle_selected_item(&mut self) {
|
|
if self.focus != FocusArea::Items {
|
|
return;
|
|
}
|
|
let Some(selected_idx) = self.selected_item_idx else {
|
|
return;
|
|
};
|
|
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 move_up(&mut self) {
|
|
match self.focus {
|
|
FocusArea::Items => match self.selected_item_idx {
|
|
Some(0) => {
|
|
self.focus = FocusArea::Actions;
|
|
self.highlighted_action = ActionMenuOption::SkipForever;
|
|
}
|
|
Some(idx) => {
|
|
self.selected_item_idx = Some(idx.saturating_sub(1));
|
|
}
|
|
None => {
|
|
self.focus = FocusArea::Actions;
|
|
self.highlighted_action = ActionMenuOption::SkipForever;
|
|
}
|
|
},
|
|
FocusArea::Actions => {
|
|
if let Some(previous) = self.previous_available_action(self.highlighted_action) {
|
|
self.highlighted_action = previous;
|
|
} else {
|
|
self.focus = FocusArea::Items;
|
|
if !self.items.is_empty() {
|
|
self.selected_item_idx = Some(self.items.len() - 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.ensure_selected_item_visible();
|
|
self.request_frame.schedule_frame();
|
|
}
|
|
|
|
fn move_down(&mut self) {
|
|
match self.focus {
|
|
FocusArea::Items => match self.selected_item_idx {
|
|
Some(idx) if idx + 1 < self.items.len() => {
|
|
self.selected_item_idx = Some(idx + 1);
|
|
}
|
|
_ => {
|
|
self.focus = FocusArea::Actions;
|
|
self.highlighted_action = self.first_available_action();
|
|
}
|
|
},
|
|
FocusArea::Actions => {
|
|
if let Some(next) = self.next_available_action(self.highlighted_action) {
|
|
self.highlighted_action = next;
|
|
} else {
|
|
self.focus = FocusArea::Items;
|
|
if !self.items.is_empty() {
|
|
self.selected_item_idx = Some(0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.ensure_selected_item_visible();
|
|
self.request_frame.schedule_frame();
|
|
}
|
|
|
|
fn confirm_selection(&mut self) {
|
|
match self.focus {
|
|
FocusArea::Items => self.toggle_selected_item(),
|
|
FocusArea::Actions => match self.highlighted_action {
|
|
ActionMenuOption::Proceed => self.proceed(),
|
|
ActionMenuOption::Skip => self.skip(),
|
|
ActionMenuOption::SkipForever => self.skip_forever(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handle_key(&mut self, key_event: KeyEvent) {
|
|
if key_event.kind == KeyEventKind::Release {
|
|
return;
|
|
}
|
|
|
|
if is_ctrl_exit_combo(key_event) {
|
|
self.exit();
|
|
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('2') => {
|
|
self.focus = FocusArea::Actions;
|
|
self.highlighted_action = ActionMenuOption::Skip;
|
|
self.skip();
|
|
}
|
|
KeyCode::Char('3') => {
|
|
self.focus = FocusArea::Actions;
|
|
self.highlighted_action = ActionMenuOption::SkipForever;
|
|
self.skip_forever();
|
|
}
|
|
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(),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn ensure_selected_item_visible(&mut self) {
|
|
let Some(selected_idx) = self.selected_item_idx else {
|
|
self.scroll_top = 0;
|
|
return;
|
|
};
|
|
let selected_render_idx = self.selected_render_line_index(selected_idx);
|
|
let visible_rows = self.render_line_count().max(1);
|
|
if selected_render_idx < self.scroll_top {
|
|
self.scroll_top = selected_render_idx;
|
|
} else {
|
|
let bottom = self.scroll_top + visible_rows.saturating_sub(1);
|
|
if selected_render_idx > bottom {
|
|
self.scroll_top = selected_render_idx + 1 - visible_rows;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_line_count(&self) -> usize {
|
|
self.build_render_lines().len()
|
|
}
|
|
|
|
fn selected_render_line_index(&self, selected_item_idx: usize) -> usize {
|
|
self.build_render_lines()
|
|
.iter()
|
|
.position(|entry| entry.item_idx == Some(selected_item_idx))
|
|
.unwrap_or(selected_item_idx)
|
|
}
|
|
|
|
fn section_title(cwd: Option<&std::path::Path>) -> Line<'static> {
|
|
match cwd {
|
|
Some(cwd) => Line::from(vec!["Project: ".bold(), cwd.display().to_string().dim()]),
|
|
None => Line::from("Home".bold()),
|
|
}
|
|
}
|
|
|
|
fn build_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() {
|
|
let scope = item.item.cwd.as_deref();
|
|
if current_scope != Some(scope) {
|
|
if current_scope.is_some() {
|
|
lines.push(RenderLineEntry {
|
|
item_idx: None,
|
|
kind: RenderLineKind::Section,
|
|
line: Line::from(""),
|
|
});
|
|
}
|
|
lines.push(RenderLineEntry {
|
|
item_idx: None,
|
|
kind: RenderLineKind::Section,
|
|
line: Self::section_title(scope),
|
|
});
|
|
current_scope = Some(scope);
|
|
}
|
|
lines.push(RenderLineEntry {
|
|
item_idx: Some(idx),
|
|
kind: RenderLineKind::Item,
|
|
line: Line::from(format!(
|
|
" [{}] {}",
|
|
if item.enabled { "x" } else { " " },
|
|
Self::display_description(&item.item)
|
|
)),
|
|
});
|
|
if let Some(details) = &item.item.details {
|
|
for line in Self::plugin_detail_lines(&details.plugins) {
|
|
lines.push(RenderLineEntry {
|
|
item_idx: None,
|
|
kind: RenderLineKind::ItemDetail,
|
|
line,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
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.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 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'))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::ActionMenuOption;
|
|
use super::ExternalAgentConfigMigrationOutcome;
|
|
use super::ExternalAgentConfigMigrationScreen;
|
|
use super::FocusArea;
|
|
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 crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::event::KeyModifiers;
|
|
use insta::assert_snapshot;
|
|
use pretty_assertions::assert_eq;
|
|
use ratatui::layout::Rect;
|
|
use std::path::PathBuf;
|
|
|
|
fn sample_plugin_details() -> codex_app_server_protocol::MigrationDetails {
|
|
codex_app_server_protocol::MigrationDetails {
|
|
plugins: vec![
|
|
PluginsMigration {
|
|
marketplace_name: "acme-tools".to_string(),
|
|
plugin_names: vec![
|
|
"deployer".to_string(),
|
|
"formatter".to_string(),
|
|
"lint".to_string(),
|
|
],
|
|
},
|
|
PluginsMigration {
|
|
marketplace_name: "team-marketplace".to_string(),
|
|
plugin_names: vec!["asana".to_string()],
|
|
},
|
|
PluginsMigration {
|
|
marketplace_name: "debug".to_string(),
|
|
plugin_names: vec!["sample".to_string()],
|
|
},
|
|
PluginsMigration {
|
|
marketplace_name: "data-tools".to_string(),
|
|
plugin_names: vec!["warehouse".to_string()],
|
|
},
|
|
],
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn sample_project_root() -> PathBuf {
|
|
PathBuf::from(r"C:\workspace\project")
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn sample_project_root() -> PathBuf {
|
|
PathBuf::from("/workspace/project")
|
|
}
|
|
|
|
fn sample_project_path(path: &str) -> String {
|
|
sample_project_root().join(path).display().to_string()
|
|
}
|
|
|
|
fn sample_items() -> Vec<ExternalAgentConfigMigrationItem> {
|
|
let project_root = sample_project_root();
|
|
vec![
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Config,
|
|
description:
|
|
"Migrate /Users/alex/.claude/settings.json into /Users/alex/.codex/config.toml"
|
|
.to_string(),
|
|
cwd: None,
|
|
details: None,
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
|
description: format!(
|
|
"Migrate enabled plugins from {}",
|
|
sample_project_path(".claude/settings.json")
|
|
),
|
|
cwd: Some(project_root.clone()),
|
|
details: Some(sample_plugin_details()),
|
|
},
|
|
ExternalAgentConfigMigrationItem {
|
|
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
|
description: format!(
|
|
"Migrate {} to {}",
|
|
sample_project_path("CLAUDE.md"),
|
|
sample_project_path("AGENTS.md")
|
|
),
|
|
cwd: Some(project_root),
|
|
details: None,
|
|
},
|
|
]
|
|
}
|
|
|
|
fn render_screen(
|
|
screen: &ExternalAgentConfigMigrationScreen,
|
|
width: u16,
|
|
height: u16,
|
|
) -> String {
|
|
let backend = VT100Backend::new(width, height);
|
|
let mut terminal = Terminal::with_options(backend).expect("terminal");
|
|
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
|
{
|
|
let mut frame = terminal.get_frame();
|
|
frame.render_widget_ref(screen, frame.area());
|
|
}
|
|
terminal.flush().expect("flush");
|
|
terminal.backend().to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn prompt_snapshot() {
|
|
let items = sample_items();
|
|
let screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&items,
|
|
/*error*/ None,
|
|
);
|
|
|
|
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 21);
|
|
#[cfg(windows)]
|
|
assert_snapshot!("external_agent_config_migration_prompt_windows", rendered);
|
|
#[cfg(not(windows))]
|
|
assert_snapshot!("external_agent_config_migration_prompt", rendered);
|
|
}
|
|
|
|
#[test]
|
|
fn proceed_returns_selected_items() {
|
|
let items = sample_items();
|
|
let mut screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&items,
|
|
/*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());
|
|
assert_eq!(
|
|
screen.outcome(),
|
|
ExternalAgentConfigMigrationOutcome::Proceed(items)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_item_then_proceed_keeps_remaining_selection() {
|
|
let items = sample_items();
|
|
let mut screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&items,
|
|
/*error*/ 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));
|
|
|
|
assert!(screen.is_done());
|
|
assert_eq!(
|
|
screen.outcome(),
|
|
ExternalAgentConfigMigrationOutcome::Proceed(vec![items[1].clone(), items[2].clone(),])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn escape_skips_prompt() {
|
|
let items = sample_items();
|
|
let mut screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&items,
|
|
/*error*/ None,
|
|
);
|
|
|
|
screen.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
|
|
assert!(screen.is_done());
|
|
assert_eq!(screen.outcome(), ExternalAgentConfigMigrationOutcome::Skip);
|
|
}
|
|
|
|
#[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() {
|
|
let items = sample_items();
|
|
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::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}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn proceed_action_is_skipped_when_no_items_are_selected() {
|
|
let items = sample_items();
|
|
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));
|
|
|
|
assert_eq!(screen.focus, FocusArea::Actions);
|
|
assert_eq!(screen.highlighted_action, ActionMenuOption::Skip);
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_shortcuts_choose_actions() {
|
|
let items = sample_items();
|
|
|
|
let mut proceed_screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&items,
|
|
/*error*/ None,
|
|
);
|
|
proceed_screen.handle_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
proceed_screen.outcome(),
|
|
ExternalAgentConfigMigrationOutcome::Proceed(items.clone())
|
|
);
|
|
|
|
let mut skip_screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&items,
|
|
/*error*/ None,
|
|
);
|
|
skip_screen.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
skip_screen.outcome(),
|
|
ExternalAgentConfigMigrationOutcome::Skip
|
|
);
|
|
|
|
let mut skip_forever_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
|
|
);
|
|
}
|
|
}
|