Codex Handbook
tui/src/external_agent_config_migration.rs 1043 lines
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);    }}