mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] add external agent import picker UX (#27070)
## Why Users need to understand what external-agent data Codex detected, what is selected, and how to proceed before an import begins. The updated picker makes focus, selection state, and the submission path explicit while preserving the existing import backend. ## What changed - replace the old migration prompt with a two-step external-agent import picker - add a customize view with explicit item focus, selection state, counts, and a review action - separate detected import data into a view model - add Unix and Windows snapshots for prompt, item-focus, and action-focus states ## Validation - `just test -p codex-tui external_agent_config_migration` (10 passed) - manually exercised an isolated TUI fixture covering customization, selection toggles, review, import, repeated invocation, and session resume - the broader `just test -p codex-tui` run passed 2,805 tests, with 2 unrelated guardian feature-flag failures and 4 skipped tests ## Review note This is the largest layer in the stack because the interaction state, rendering changes, and required snapshots move together. It remains a draft in case reviewers prefer a further presentation/state split. ## Stack 1. [#27064](https://github.com/openai/codex/pull/27064): remove the startup migration flow 2. [#27065](https://github.com/openai/codex/pull/27065): extract the picker renderer 3. [#27070](https://github.com/openai/codex/pull/27070): add the external-agent import picker UX 4. [#27071](https://github.com/openai/codex/pull/27071): expose the flow through `/import` **This PR is stack item 3.** Draft while the lower stack dependencies are reviewed.
This commit is contained in:
committed by
GitHub
Unverified
parent
13468115fc
commit
e3528434cd
@@ -1,6 +1,8 @@
|
||||
use crate::diff_render::display_path_for;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
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;
|
||||
@@ -10,11 +12,8 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Widget;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
mod render;
|
||||
@@ -23,8 +22,6 @@ mod render;
|
||||
pub(crate) enum ExternalAgentConfigMigrationOutcome {
|
||||
Proceed(Vec<ExternalAgentConfigMigrationItem>),
|
||||
Skip,
|
||||
SkipForever,
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -36,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 => "Review selection",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)]
|
||||
@@ -128,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,
|
||||
@@ -143,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(
|
||||
@@ -199,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 {
|
||||
@@ -252,7 +256,7 @@ impl ExternalAgentConfigMigrationScreen {
|
||||
return description;
|
||||
}
|
||||
|
||||
item.description.clone()
|
||||
description
|
||||
}
|
||||
|
||||
fn new(
|
||||
@@ -261,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()
|
||||
@@ -269,18 +274,22 @@ impl ExternalAgentConfigMigrationScreen {
|
||||
item,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let selected_item_idx = (!items.is_empty()).then_some(0);
|
||||
Self {
|
||||
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::Items,
|
||||
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>> {
|
||||
@@ -333,12 +342,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));
|
||||
}
|
||||
|
||||
@@ -346,14 +349,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()
|
||||
@@ -366,6 +361,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;
|
||||
@@ -376,7 +387,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 {
|
||||
@@ -385,26 +396,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 => {
|
||||
@@ -423,6 +459,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() => {
|
||||
@@ -453,8 +497,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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -463,39 +508,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;
|
||||
@@ -526,12 +579,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() {
|
||||
@@ -554,12 +642,28 @@ impl ExternalAgentConfigMigrationScreen {
|
||||
lines.push(RenderLineEntry {
|
||||
item_idx: Some(idx),
|
||||
kind: RenderLineKind::Item,
|
||||
line: Line::from(format!(
|
||||
" [{}] {}",
|
||||
if item.enabled { "x" } else { " " },
|
||||
Self::display_description(&item.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 {
|
||||
@@ -572,76 +676,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -703,6 +756,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!(
|
||||
@@ -738,7 +804,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]
|
||||
@@ -751,13 +823,56 @@ 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 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();
|
||||
@@ -768,9 +883,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());
|
||||
@@ -790,16 +902,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(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -820,30 +935,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(),
|
||||
@@ -852,35 +944,46 @@ 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 empty_selection_enter_opens_customize_instead_of_proceeding() {
|
||||
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));
|
||||
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(screen.focus, FocusArea::Actions);
|
||||
assert_eq!(screen.highlighted_action, ActionMenuOption::Skip);
|
||||
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]
|
||||
@@ -899,28 +1002,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,98 @@
|
||||
use super::ActionMenuOption;
|
||||
use super::ExternalAgentConfigMigrationScreen;
|
||||
use super::FocusArea;
|
||||
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 crossterm::event::KeyCode;
|
||||
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;
|
||||
|
||||
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 {
|
||||
if let Some(cursor) = line.spans.first_mut() {
|
||||
cursor.content = "› ".into();
|
||||
}
|
||||
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 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)
|
||||
@@ -40,25 +108,26 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
|
||||
_spacer_area,
|
||||
] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(intro_height),
|
||||
Constraint::Length(error_height),
|
||||
Constraint::Length(list_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(actions_height),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(inner_area);
|
||||
|
||||
let heading = Line::from(vec!["> ".into(), "External agent config detected".bold()]);
|
||||
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(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);
|
||||
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())
|
||||
@@ -69,69 +138,69 @@ impl WidgetRef for &ExternalAgentConfigMigrationScreen {
|
||||
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([
|
||||
let action_areas = Layout::vertical(std::iter::repeat_n(
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(actions_area);
|
||||
actions.len() + 1,
|
||||
))
|
||||
.split(actions_area);
|
||||
let item_label = if self.items.len() == 1 {
|
||||
"item"
|
||||
} else {
|
||||
"items"
|
||||
};
|
||||
let actions_intro = format!(
|
||||
"Selected {} of {} item(s).",
|
||||
"Selected {} of {} {item_label}.",
|
||||
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);
|
||||
.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);
|
||||
}
|
||||
|
||||
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);
|
||||
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 if self.focus == FocusArea::Actions => Line::from(vec![
|
||||
"Press ".dim(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to continue, ".dim(),
|
||||
key_hint::plain(KeyCode::Up).into(),
|
||||
"/".dim(),
|
||||
key_hint::plain(KeyCode::Down).into(),
|
||||
" to move, ".dim(),
|
||||
"b".cyan(),
|
||||
" to go back".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,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(", ")),
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ mod exec_cell;
|
||||
mod exec_command;
|
||||
#[allow(dead_code)]
|
||||
mod external_agent_config_migration;
|
||||
mod external_agent_config_migration_model;
|
||||
mod external_editor;
|
||||
mod file_search;
|
||||
mod frames;
|
||||
|
||||
+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. Review selection
|
||||
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: /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. Review selection
|
||||
Press enter to continue, ↑/↓ to move, 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. Review selection
|
||||
Press enter to continue, ↑/↓ to move, 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. Review selection
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user