mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Summary - restore source-specific wording across the `/import` picker, lifecycle messages, diagnostics, and help text - update the matching Unix and Windows snapshots - leave import behavior unchanged ## Why The import path currently supports one source, so the UI should identify that source directly instead of presenting the flow as provider-agnostic. ## Validation - `just test -p codex-tui external_agent_config_migration` (12 passed) - `just fix -p codex-tui` - `just fmt`
1044 lines
35 KiB
Rust
1044 lines
35 KiB
Rust
use crate::diff_render::display_path_for;
|
|
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;
|
|
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::prelude::Stylize as _;
|
|
use ratatui::text::Line;
|
|
use tokio_stream::StreamExt;
|
|
|
|
mod render;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub(crate) enum ExternalAgentConfigMigrationOutcome {
|
|
Proceed(Vec<ExternalAgentConfigMigrationItem>),
|
|
Skip,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum FocusArea {
|
|
Items,
|
|
Actions,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum ActionMenuOption {
|
|
Proceed,
|
|
Customize,
|
|
Skip,
|
|
Back,
|
|
}
|
|
|
|
impl ActionMenuOption {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Proceed => "Import selected",
|
|
Self::Customize => "Customize selection",
|
|
Self::Skip => "Cancel",
|
|
Self::Back => "Review selection",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum MigrationView {
|
|
Summary,
|
|
Customize,
|
|
}
|
|
|
|
#[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>,
|
|
groups: Vec<ExternalAgentConfigMigrationGroupModel>,
|
|
view: MigrationView,
|
|
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 {
|
|
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 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 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
|
|
}
|
|
MigrationView::Customize => vec![ActionMenuOption::Back],
|
|
}
|
|
}
|
|
|
|
fn normalize_highlighted_action(&mut self) {
|
|
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 description;
|
|
};
|
|
|
|
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(&description, "Import ", " into ", cwd) {
|
|
return reformatted;
|
|
}
|
|
|
|
if let Some(reformatted) =
|
|
reformat_description(&description, "Import skills from ", " to ", cwd)
|
|
{
|
|
return reformatted;
|
|
}
|
|
|
|
if let Some(reformatted) = reformat_description(&description, "Import ", " to ", cwd) {
|
|
return reformatted;
|
|
}
|
|
|
|
if let Some(source) = description.strip_prefix("Import enabled plugins from ") {
|
|
let description = format!(
|
|
"Import 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;
|
|
}
|
|
|
|
description
|
|
}
|
|
|
|
fn new(
|
|
request_frame: FrameRequester,
|
|
items: &[ExternalAgentConfigMigrationItem],
|
|
selected_items: &[ExternalAgentConfigMigrationItem],
|
|
error: Option<String>,
|
|
) -> Self {
|
|
let groups = external_agent_config_migration_groups(items);
|
|
let items = items
|
|
.iter()
|
|
.cloned()
|
|
.map(|item| MigrationSelection {
|
|
enabled: selected_items.contains(&item),
|
|
item,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let selected_item_idx = (!groups.is_empty()).then_some(0);
|
|
let mut screen = Self {
|
|
request_frame,
|
|
items,
|
|
groups,
|
|
view: MigrationView::Summary,
|
|
selected_item_idx,
|
|
scroll_top: 0,
|
|
focus: FocusArea::Actions,
|
|
highlighted_action: ActionMenuOption::Proceed,
|
|
done: false,
|
|
outcome: ExternalAgentConfigMigrationOutcome::Skip,
|
|
error,
|
|
};
|
|
screen.normalize_highlighted_action();
|
|
screen
|
|
}
|
|
|
|
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();
|
|
self.finish_with(ExternalAgentConfigMigrationOutcome::Proceed(selected));
|
|
}
|
|
|
|
fn skip(&mut self) {
|
|
self.finish_with(ExternalAgentConfigMigrationOutcome::Skip);
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
self.error = None;
|
|
self.normalize_highlighted_action();
|
|
self.request_frame.schedule_frame();
|
|
}
|
|
|
|
fn toggle_selected_item(&mut self) {
|
|
if self.view != MigrationView::Customize || 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 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 = self.last_available_action();
|
|
}
|
|
Some(idx) => {
|
|
self.selected_item_idx = Some(idx.saturating_sub(1));
|
|
}
|
|
None => {
|
|
self.focus = FocusArea::Actions;
|
|
self.highlighted_action = self.last_available_action();
|
|
}
|
|
},
|
|
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) {
|
|
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() => {
|
|
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::Customize => self.customize(),
|
|
ActionMenuOption::Skip => self.skip(),
|
|
ActionMenuOption::Back => self.back_to_summary(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn handle_key(&mut self, key_event: KeyEvent) {
|
|
if key_event.kind == KeyEventKind::Release {
|
|
return;
|
|
}
|
|
if is_ctrl_exit_combo(key_event) {
|
|
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(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('a') if self.view == MigrationView::Customize => {
|
|
self.set_all_enabled(/*enabled*/ true);
|
|
}
|
|
KeyCode::Char('n') if self.view == MigrationView::Customize => {
|
|
self.set_all_enabled(/*enabled*/ false);
|
|
}
|
|
KeyCode::Enter => self.confirm_selection(),
|
|
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;
|
|
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![
|
|
"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() {
|
|
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(vec![
|
|
" ".into(),
|
|
format!(
|
|
"[{}] {}",
|
|
if item.enabled { "x" } else { " " },
|
|
external_agent_config_migration_item_label(&item.item)
|
|
)
|
|
.into(),
|
|
]),
|
|
});
|
|
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 {
|
|
item_idx: None,
|
|
kind: RenderLineKind::ItemDetail,
|
|
line,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
}
|
|
|
|
fn is_ctrl_exit_combo(key_event: KeyEvent) -> bool {
|
|
matches!(key_event.code, KeyCode::Char('c' | 'd'))
|
|
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::ExternalAgentConfigMigrationOutcome;
|
|
use super::ExternalAgentConfigMigrationScreen;
|
|
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;
|
|
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::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!(
|
|
"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()
|
|
.lines()
|
|
.map(str::trim_end)
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
#[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*/ 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 customize_action_snapshot() {
|
|
let items = sample_items();
|
|
let mut screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&items,
|
|
/*error*/ None,
|
|
);
|
|
screen.customize();
|
|
screen.move_up();
|
|
|
|
let rendered = render_screen(&screen, /*width*/ 80, /*height*/ 30);
|
|
#[cfg(windows)]
|
|
assert_snapshot!(
|
|
"external_agent_config_migration_customize_action_windows",
|
|
rendered
|
|
);
|
|
#[cfg(not(windows))]
|
|
assert_snapshot!("external_agent_config_migration_customize_action", 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::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('c'), KeyModifiers::NONE));
|
|
screen.handle_key(KeyEvent::new(KeyCode::Char(' '), 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(),
|
|
items[3].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 numeric_shortcuts_follow_visible_actions_when_proceed_is_disabled() {
|
|
let items = sample_items();
|
|
let mut screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&items,
|
|
/*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_eq!(screen.view, MigrationView::Customize);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_selection_enter_opens_customize_instead_of_proceeding() {
|
|
let items = sample_items();
|
|
let mut screen = ExternalAgentConfigMigrationScreen::new(
|
|
FrameRequester::test_dummy(),
|
|
&items,
|
|
&[],
|
|
/*error*/ None,
|
|
);
|
|
|
|
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
|
|
|
assert!(!screen.is_done());
|
|
assert_eq!(screen.view, MigrationView::Customize);
|
|
}
|
|
|
|
#[test]
|
|
fn control_exit_shortcuts_cancel_prompt() {
|
|
let items = sample_items();
|
|
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(key_code, KeyModifiers::CONTROL));
|
|
|
|
assert!(screen.is_done());
|
|
assert_eq!(screen.outcome(), ExternalAgentConfigMigrationOutcome::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 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('3'), KeyModifiers::NONE));
|
|
assert_eq!(
|
|
skip_screen.outcome(),
|
|
ExternalAgentConfigMigrationOutcome::Skip
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn summary_does_not_toggle_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));
|
|
|
|
assert_eq!(screen.selected_items(), items);
|
|
}
|
|
}
|