Skip to main content

cc_switch/interactive/
codex_interactive.rs

1use crate::codex::{CodexConfiguration, write_auth_json};
2use crate::cli::display_utils::{
3    TextAlignment, get_terminal_width, pad_text_to_width, text_display_width,
4};
5use crate::config::types::ConfigStorage;
6use crate::interactive::interactive::{
7    BorderDrawing, EditModeError, cleanup_terminal, edit_optional_string_field, edit_string_field,
8};
9use anyhow::Result;
10use colored::*;
11use crossterm::{
12    event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
13    execute, terminal,
14};
15use std::io::{self, Write};
16use std::process::Command;
17
18/// Handle interactive Codex configuration selection with full TUI
19///
20/// Mirrors the Claude interactive TUI: alternate screen, arrow key / j/k navigation,
21/// number key shortcuts (1-9), pagination (9 per page), config detail preview.
22///
23/// # Arguments
24/// * `storage` - Reference to configuration storage
25///
26/// # Errors
27/// Returns error if terminal operations fail or user selection fails
28pub fn handle_codex_interactive_selection(storage: &ConfigStorage) -> Result<()> {
29    let configs_map = match &storage.codex_configurations {
30        Some(configs) if !configs.is_empty() => configs,
31        _ => {
32            println!("No Codex configurations available. Use 'cc-switch codex add' to create configurations first.");
33            return Ok(());
34        }
35    };
36
37    let mut configs: Vec<CodexConfiguration> = configs_map.values().cloned().collect();
38    configs.sort_by(|a, b| a.alias_name.cmp(&b.alias_name));
39
40    let mut selected_index: usize = 0;
41
42    // Try to enable raw mode, fallback to simple menu if it fails
43    let raw_mode_enabled = terminal::enable_raw_mode().is_ok();
44
45    if raw_mode_enabled {
46        let mut stdout = io::stdout();
47        if execute!(
48            stdout,
49            terminal::EnterAlternateScreen,
50            terminal::Clear(terminal::ClearType::All)
51        )
52        .is_ok()
53        {
54            let result = handle_codex_full_interactive_menu(
55                &mut stdout,
56                &mut configs,
57                &mut selected_index,
58            );
59
60            // Always restore terminal
61            let _ = execute!(stdout, terminal::LeaveAlternateScreen);
62            let _ = terminal::disable_raw_mode();
63
64            return result;
65        } else {
66            let _ = terminal::disable_raw_mode();
67        }
68    }
69
70    // Fallback to simple numbered menu
71    handle_codex_simple_interactive_menu(&configs)
72}
73
74/// Format Codex configuration details for display
75///
76/// # Arguments
77/// * `config` - The configuration to format
78/// * `indent` - Base indentation string (e.g., "    " or "   ")
79///
80/// # Returns
81/// Vector of formatted lines for configuration display
82fn format_codex_config_details(config: &CodexConfiguration, indent: &str) -> Vec<String> {
83    let mut lines = Vec::new();
84
85    let terminal_width = get_terminal_width();
86    let _available_width = terminal_width.saturating_sub(text_display_width(indent) + 8);
87
88    // Field labels with consistent width for alignment
89    let auth_mode_label = "Auth Mode:";
90    let account_id_label = "Account ID:";
91    let api_key_label = "API Key:";
92    let last_refresh_label = "Last Refresh:";
93
94    let max_label_width = [
95        auth_mode_label,
96        account_id_label,
97        api_key_label,
98        last_refresh_label,
99    ]
100    .iter()
101    .map(|label| text_display_width(label))
102    .max()
103    .unwrap_or(0);
104
105    // Auth mode (always shown)
106    let mode_value = if config.auth_mode == "apikey" {
107        "apikey".cyan()
108    } else {
109        "chatgpt".cyan()
110    };
111    lines.push(format!(
112        "{}{} {}",
113        indent,
114        pad_text_to_width(auth_mode_label, max_label_width, TextAlignment::Left, ' '),
115        mode_value
116    ));
117
118    // Account ID (chatgpt mode)
119    if let Some(ref account_id) = config.account_id {
120        lines.push(format!(
121            "{}{} {}",
122            indent,
123            pad_text_to_width(account_id_label, max_label_width, TextAlignment::Left, ' '),
124            account_id.yellow()
125        ));
126    }
127
128    // API key prefix (apikey mode)
129    if let Some(ref key) = config.openai_api_key {
130        let prefix = if key.len() > 8 {
131            format!("{}...", &key[..8])
132        } else {
133            key.clone()
134        };
135        lines.push(format!(
136            "{}{} {}",
137            indent,
138            pad_text_to_width(api_key_label, max_label_width, TextAlignment::Left, ' '),
139            prefix.dimmed()
140        ));
141    }
142
143    // Last refresh (chatgpt mode)
144    if let Some(ref last_refresh) = config.last_refresh {
145        lines.push(format!(
146            "{}{} {}",
147            indent,
148            pad_text_to_width(last_refresh_label, max_label_width, TextAlignment::Left, ' '),
149            last_refresh.dimmed()
150        ));
151    }
152
153    lines
154}
155
156/// Handle full interactive menu with arrow key navigation and pagination for Codex
157#[allow(clippy::ptr_arg)]
158fn handle_codex_full_interactive_menu(
159    stdout: &mut io::Stdout,
160    configs: &mut Vec<CodexConfiguration>,
161    selected_index: &mut usize,
162) -> Result<()> {
163    if configs.is_empty() {
164        println!("\r{}", "No Codex configurations available".yellow());
165        println!(
166            "\r{}",
167            "Use 'cc-switch codex add' to add configurations first.".dimmed()
168        );
169        println!("\r{}", "Press any key to continue...".dimmed());
170        let _ = event::read();
171        return Ok(());
172    }
173
174    const PAGE_SIZE: usize = 9;
175
176    let total_pages = if configs.len() <= PAGE_SIZE {
177        1
178    } else {
179        configs.len().div_ceil(PAGE_SIZE)
180    };
181    let mut current_page = 0;
182
183    loop {
184        let start_idx = current_page * PAGE_SIZE;
185        let end_idx = std::cmp::min(start_idx + PAGE_SIZE, configs.len());
186        let page_configs = &configs[start_idx..end_idx];
187
188        // Clear screen and redraw
189        execute!(stdout, terminal::Clear(terminal::ClearType::All))?;
190        execute!(stdout, crossterm::cursor::MoveTo(0, 0))?;
191
192        let border = BorderDrawing::new();
193        const CONFIG_MENU_WIDTH: usize = 80;
194
195        println!(
196            "\r{}",
197            border
198                .draw_top_border("Select Codex Configuration", CONFIG_MENU_WIDTH)
199                .green()
200        );
201        if total_pages > 1 {
202            println!(
203                "\r{}",
204                border
205                    .draw_middle_line(
206                        &format!("第 {} 页,共 {} 页", current_page + 1, total_pages),
207                        CONFIG_MENU_WIDTH
208                    )
209                    .green()
210            );
211            println!(
212                "\r{}",
213                border
214                    .draw_middle_line(
215                        "↑↓/jk导航,1-9快选,N/P翻页,E编辑,Q-退出,Enter确认",
216                        CONFIG_MENU_WIDTH
217                    )
218                    .green()
219            );
220        } else {
221            println!(
222                "\r{}",
223                border
224                    .draw_middle_line(
225                        "↑↓/jk导航,1-9快选,E编辑,Q-退出,Enter确认,Esc取消",
226                        CONFIG_MENU_WIDTH
227                    )
228                    .green()
229            );
230        }
231        println!("\r{}", border.draw_bottom_border(CONFIG_MENU_WIDTH).green());
232        println!();
233
234        // Draw current page configs with proper numbering
235        // No "official" option for Codex; indices:
236        //   0 .. configs.len()-1  -> config entries
237        //   configs.len()         -> Exit option
238        for (page_index, config) in page_configs.iter().enumerate() {
239            let actual_config_index = start_idx + page_index;
240            let display_number = page_index + 1; // Numbers 1-9 for current page
241            let number_label = format!("[{display_number}]");
242
243            if *selected_index == actual_config_index {
244                println!(
245                    "\r> {} {} {}",
246                    "●".blue().bold(),
247                    number_label.blue().bold(),
248                    config.alias_name.blue().bold()
249                );
250
251                let details = format_codex_config_details(config, "\r    ");
252                for detail_line in details {
253                    println!("{detail_line}");
254                }
255                println!();
256            } else {
257                println!(
258                    "\r  {} {} {}",
259                    "○".dimmed(),
260                    number_label.dimmed(),
261                    config.alias_name.dimmed()
262                );
263            }
264        }
265
266        // Add exit option at the end
267        let exit_index = configs.len();
268        if *selected_index == exit_index {
269            println!(
270                "\r> {} {} {}",
271                "●".yellow().bold(),
272                "[Q]".yellow().bold(),
273                "Exit".yellow().bold()
274            );
275            println!("\r    Exit without making changes");
276            println!();
277        } else {
278            println!(
279                "\r  {} {} {}",
280                "○".dimmed(),
281                "[Q]".dimmed(),
282                "Exit".dimmed()
283            );
284        }
285
286        // Show pagination help if needed
287        if total_pages > 1 {
288            println!(
289                "\r{}",
290                format!(
291                    "Page Navigation: [N]ext, [P]revious (第 {} 页,共 {} 页)",
292                    current_page + 1,
293                    total_pages
294                )
295                .dimmed()
296            );
297        }
298
299        stdout.flush()?;
300
301        // Handle input with error recovery
302        let event = match event::read() {
303            Ok(event) => event,
304            Err(e) => {
305                cleanup_terminal(stdout);
306                return Err(e.into());
307            }
308        };
309
310        match event {
311            Event::Key(KeyEvent {
312                code,
313                kind: KeyEventKind::Press,
314                ..
315            }) => match code {
316                KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('K') => {
317                    *selected_index = selected_index.saturating_sub(1);
318                }
319                KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('J') => {
320                    // configs.len() is the Exit option; cannot go beyond it
321                    if *selected_index < configs.len() {
322                        *selected_index += 1;
323                    }
324                }
325                KeyCode::PageDown | KeyCode::Char('n') | KeyCode::Char('N') => {
326                    if total_pages > 1 && current_page < total_pages - 1 {
327                        current_page += 1;
328                        // Reset selection to first item of new page
329                        let new_page_start_idx = current_page * PAGE_SIZE;
330                        *selected_index = new_page_start_idx;
331                    }
332                }
333                KeyCode::PageUp | KeyCode::Char('p') | KeyCode::Char('P') => {
334                    if total_pages > 1 && current_page > 0 {
335                        current_page -= 1;
336                        let new_page_start_idx = current_page * PAGE_SIZE;
337                        *selected_index = new_page_start_idx;
338                    }
339                }
340                KeyCode::Enter => {
341                    cleanup_terminal(stdout);
342                    return handle_codex_selection_action(configs, *selected_index);
343                }
344                KeyCode::Esc => {
345                    cleanup_terminal(stdout);
346                    println!("\nSelection cancelled");
347                    return Ok(());
348                }
349                KeyCode::Char(c) if c.is_ascii_digit() => {
350                    let digit = c.to_digit(10).unwrap() as usize;
351                    if digit >= 1 && digit <= page_configs.len() {
352                        let actual_config_index = start_idx + (digit - 1);
353                        cleanup_terminal(stdout);
354                        return handle_codex_selection_action(configs, actual_config_index);
355                    }
356                }
357                KeyCode::Char('e') | KeyCode::Char('E') => {
358                    if *selected_index < configs.len() {
359                        cleanup_terminal(stdout);
360
361                        let edit_result =
362                            handle_codex_config_edit(&configs[*selected_index]);
363
364                        if execute!(
365                            stdout,
366                            terminal::EnterAlternateScreen,
367                            terminal::Clear(terminal::ClearType::All)
368                        )
369                        .is_ok()
370                            && terminal::enable_raw_mode().is_ok()
371                        {
372                            match edit_result {
373                                Ok(_) => {
374                                    if let Ok(reloaded_storage) = ConfigStorage::load() {
375                                        if let Some(ref map) =
376                                            reloaded_storage.codex_configurations
377                                        {
378                                            *configs = map.values().cloned().collect();
379                                            configs.sort_by(|a, b| {
380                                                a.alias_name.cmp(&b.alias_name)
381                                            });
382                                        }
383                                        if *selected_index > configs.len() {
384                                            *selected_index =
385                                                configs.len().saturating_sub(1);
386                                        }
387                                    }
388                                    continue;
389                                }
390                                Err(e) => {
391                                    if e.downcast_ref::<EditModeError>()
392                                        == Some(&EditModeError::ReturnToMenu)
393                                    {
394                                        continue;
395                                    }
396                                    cleanup_terminal(stdout);
397                                    return Err(e);
398                                }
399                            }
400                        }
401                    }
402                }
403                KeyCode::Char('q') | KeyCode::Char('Q') => {
404                    cleanup_terminal(stdout);
405                    return handle_codex_selection_action(configs, configs.len());
406                }
407                _ => {}
408            },
409            Event::Key(_) => {}
410            _ => {}
411        }
412    }
413}
414
415/// Handle simple interactive menu (fallback) for Codex
416fn handle_codex_simple_interactive_menu(configs: &[CodexConfiguration]) -> Result<()> {
417    const PAGE_SIZE: usize = 9;
418
419    if configs.len() <= PAGE_SIZE {
420        return handle_codex_simple_single_page_menu(configs);
421    }
422
423    // Multi-page simple menu
424    let total_pages = configs.len().div_ceil(PAGE_SIZE);
425    let mut current_page = 0;
426
427    loop {
428        let start_idx = current_page * PAGE_SIZE;
429        let end_idx = std::cmp::min(start_idx + PAGE_SIZE, configs.len());
430        let page_configs = &configs[start_idx..end_idx];
431
432        println!("\n{}", "Available Codex Configurations:".blue().bold());
433        println!("第 {} 页,共 {} 页", current_page + 1, total_pages);
434        println!("使用 'n' 下一页, 'p' 上一页, 'q' 退出");
435        println!();
436
437        for (page_index, config) in page_configs.iter().enumerate() {
438            let display_number = page_index + 1;
439            println!(
440                "{}. {}",
441                format!("[{display_number}]").green().bold(),
442                config.alias_name.green()
443            );
444
445            let details = format_codex_config_details(config, "   ");
446            for detail_line in details {
447                println!("{detail_line}");
448            }
449            println!();
450        }
451
452        println!("{} {}", "[q]".yellow().bold(), "Exit".yellow());
453
454        println!(
455            "\n页面导航: [n]下页, [p]上页 | 配置选择: [1-{}] | [q]退出",
456            page_configs.len()
457        );
458
459        print!("\n请输入选择: ");
460        io::stdout().flush()?;
461
462        let mut input = String::new();
463        io::stdin().read_line(&mut input)?;
464        let choice = input.trim().to_lowercase();
465
466        match choice.as_str() {
467            "q" => {
468                println!("Exiting...");
469                return Ok(());
470            }
471            "n" if total_pages > 1 && current_page < total_pages - 1 => {
472                current_page += 1;
473                continue;
474            }
475            "p" if total_pages > 1 && current_page > 0 => {
476                current_page -= 1;
477                continue;
478            }
479            digit_str => {
480                if let Ok(digit) = digit_str.parse::<usize>()
481                    && digit >= 1
482                    && digit <= page_configs.len()
483                {
484                    let actual_config_index = start_idx + (digit - 1);
485                    return handle_codex_selection_action(configs, actual_config_index);
486                }
487                println!("无效选择,请重新输入");
488            }
489        }
490    }
491}
492
493/// Handle simple single page menu (original behavior for ≤9 configs)
494fn handle_codex_simple_single_page_menu(configs: &[CodexConfiguration]) -> Result<()> {
495    println!("\n{}", "Available Codex Configurations:".blue().bold());
496
497    for (index, config) in configs.iter().enumerate() {
498        println!("{}. {}", index + 1, config.alias_name.green());
499
500        let details = format_codex_config_details(config, "   ");
501        for detail_line in details {
502            println!("{detail_line}");
503        }
504        println!();
505    }
506
507    println!("{}. {}", configs.len() + 1, "Exit".yellow());
508
509    print!("\nSelect configuration (1-{}): ", configs.len() + 1);
510    io::stdout().flush()?;
511
512    let mut input = String::new();
513    io::stdin().read_line(&mut input)?;
514
515    match input.trim().parse::<usize>() {
516        Ok(num) if num >= 1 && num <= configs.len() => {
517            handle_codex_selection_action(configs, num - 1)
518        }
519        Ok(num) if num == configs.len() + 1 => {
520            println!("Exiting...");
521            Ok(())
522        }
523        _ => {
524            println!("Invalid selection");
525            Ok(())
526        }
527    }
528}
529
530/// Handle the actual selection and configuration switch for Codex
531///
532/// `selected_index` semantics:
533///   - 0 .. configs.len()-1: select a config
534///   - configs.len(): exit
535fn handle_codex_selection_action(
536    configs: &[CodexConfiguration],
537    selected_index: usize,
538) -> Result<()> {
539    if selected_index < configs.len() {
540        let selected_config = &configs[selected_index];
541
542        println!(
543            "\nSwitching to Codex configuration '{}'",
544            selected_config.alias_name.green().bold()
545        );
546
547        let details = format_codex_config_details(selected_config, "");
548        for detail_line in details {
549            println!("{detail_line}");
550        }
551
552        // Write auth.json
553        write_auth_json(selected_config)?;
554
555        // Launch codex
556        launch_codex_from_interactive()
557    } else {
558        println!("\nExiting...");
559        Ok(())
560    }
561}
562
563/// Launch Codex CLI from the interactive menu
564fn launch_codex_from_interactive() -> Result<()> {
565    println!("\nLaunching Codex CLI...");
566
567    #[cfg(unix)]
568    {
569        use std::os::unix::process::CommandExt;
570        let mut command = Command::new("codex");
571        let error = command.exec();
572        anyhow::bail!("Failed to exec codex: {}", error);
573    }
574
575    #[cfg(not(unix))]
576    {
577        use std::process::Stdio;
578        use anyhow::Context;
579        let mut command = Command::new("codex");
580        command
581            .stdin(Stdio::inherit())
582            .stdout(Stdio::inherit())
583            .stderr(Stdio::inherit());
584
585        let mut child = command.spawn().context(
586            "Failed to launch Codex CLI. Make sure 'codex' command is available in PATH",
587        )?;
588
589        let status = child.wait()?;
590
591        if !status.success() {
592            anyhow::bail!("Codex CLI exited with error status: {}", status);
593        }
594        Ok(())
595    }
596}
597
598/// Format a token/key for display (truncate long values)
599fn format_key_for_display(key: &str) -> String {
600    if key.len() > 8 {
601        format!("{}...", &key[..8])
602    } else {
603        key.to_string()
604    }
605}
606
607/// Handle Codex configuration editing with interactive field selection
608fn handle_codex_config_edit(config: &CodexConfiguration) -> Result<()> {
609    println!("\n{}", "Codex 配置编辑模式".green().bold());
610    println!("{}", "===================".green());
611    println!("正在编辑配置: {}", config.alias_name.cyan().bold());
612    println!();
613
614    let mut editing_config = config.clone();
615    let original_alias = config.alias_name.clone();
616
617    loop {
618        display_codex_edit_menu(&editing_config);
619
620        println!("\n{}", "提示: 可使用大小写字母".dimmed());
621        print!("请选择要编辑的字段 (1-8), 或输入 S 保存, Q 返回上一级菜单: ");
622        io::stdout().flush()?;
623
624        let mut input = String::new();
625        io::stdin().read_line(&mut input)?;
626        let input = input.trim();
627
628        match input {
629            "1" => edit_codex_field_alias(&mut editing_config)?,
630            "2" => edit_codex_field_auth_mode(&mut editing_config)?,
631            "3" => edit_codex_field_openai_api_key(&mut editing_config)?,
632            "4" => edit_codex_field_id_token(&mut editing_config)?,
633            "5" => edit_codex_field_access_token(&mut editing_config)?,
634            "6" => edit_codex_field_refresh_token(&mut editing_config)?,
635            "7" => edit_codex_field_account_id(&mut editing_config)?,
636            "8" => edit_codex_field_last_refresh(&mut editing_config)?,
637            "s" | "S" => {
638                return save_codex_configuration_changes(&original_alias, &editing_config);
639            }
640            "q" | "Q" => {
641                println!("\n{}", "返回上一级菜单".blue());
642                return Err(EditModeError::ReturnToMenu.into());
643            }
644            _ => {
645                println!("{}", "无效选择,请重试".red());
646            }
647        }
648    }
649}
650
651/// Display the Codex edit menu with current field values
652fn display_codex_edit_menu(config: &CodexConfiguration) {
653    println!("\n{}", "当前配置值:".blue().bold());
654    println!("{}", "─────────────────────────".blue());
655
656    println!("1. 别名 (alias_name): {}", config.alias_name.green());
657
658    println!(
659        "2. 认证模式 (auth_mode): {}",
660        if config.auth_mode == "apikey" {
661            "apikey".green()
662        } else {
663            "chatgpt".green()
664        }
665    );
666
667    println!(
668        "3. API密钥 (OPENAI_API_KEY): {}",
669        config
670            .openai_api_key
671            .as_deref()
672            .map(format_key_for_display)
673            .unwrap_or("[未设置]".to_string())
674            .green()
675    );
676
677    println!(
678        "4. ID令牌 (id_token): {}",
679        config
680            .id_token
681            .as_deref()
682            .map(format_key_for_display)
683            .unwrap_or("[未设置]".to_string())
684            .green()
685    );
686
687    println!(
688        "5. 访问令牌 (access_token): {}",
689        config
690            .access_token
691            .as_deref()
692            .map(format_key_for_display)
693            .unwrap_or("[未设置]".to_string())
694            .green()
695    );
696
697    println!(
698        "6. 刷新令牌 (refresh_token): {}",
699        config
700            .refresh_token
701            .as_deref()
702            .map(format_key_for_display)
703            .unwrap_or("[未设置]".to_string())
704            .green()
705    );
706
707    println!(
708        "7. 账户ID (account_id): {}",
709        config.account_id.as_deref().unwrap_or("[未设置]").green()
710    );
711
712    println!(
713        "8. 上次刷新 (last_refresh): {}",
714        config.last_refresh.as_deref().unwrap_or("[未设置]").green()
715    );
716
717    println!("{}", "─────────────────────────".blue());
718    println!(
719        "S. {} | Q. {}",
720        "保存更改".green().bold(),
721        "返回上一级菜单".blue()
722    );
723}
724
725/// Edit alias field for Codex
726fn edit_codex_field_alias(config: &mut CodexConfiguration) -> Result<()> {
727    let validator = |input: &str| -> Result<()> {
728        if input.contains(char::is_whitespace) {
729            anyhow::bail!("错误: 别名不能包含空白字符");
730        }
731        Ok(())
732    };
733
734    match edit_string_field("别名", &config.alias_name, validator) {
735        Ok(Some(new_value)) => config.alias_name = new_value,
736        Ok(None) => {}
737        Err(e) => println!("{}", e.to_string().red()),
738    }
739    Ok(())
740}
741
742/// Edit auth_mode field for Codex
743fn edit_codex_field_auth_mode(config: &mut CodexConfiguration) -> Result<()> {
744    println!("\n编辑认证模式:");
745    println!("当前值: {}", config.auth_mode.cyan());
746    print!("新值 (chatgpt/apikey, 回车保持不变): ");
747    io::stdout().flush()?;
748
749    let mut input = String::new();
750    io::stdin().read_line(&mut input)?;
751    let input = input.trim().to_lowercase();
752
753    if !input.is_empty() {
754        if input == "chatgpt" || input == "apikey" {
755            config.auth_mode = input;
756            println!("认证模式已更新为: {}", config.auth_mode.green());
757        } else {
758            println!("{}", "错误: 无效认证模式,请使用 'chatgpt' 或 'apikey'".red());
759        }
760    }
761    Ok(())
762}
763
764/// Edit openai_api_key field for Codex
765fn edit_codex_field_openai_api_key(config: &mut CodexConfiguration) -> Result<()> {
766    if let Some(result) = edit_optional_string_field(
767        "API密钥",
768        config.openai_api_key.as_deref(),
769    )? {
770        config.openai_api_key = result;
771    }
772    Ok(())
773}
774
775/// Edit id_token field for Codex
776fn edit_codex_field_id_token(config: &mut CodexConfiguration) -> Result<()> {
777    if let Some(result) = edit_optional_string_field(
778        "ID令牌",
779        config.id_token.as_deref(),
780    )? {
781        config.id_token = result;
782    }
783    Ok(())
784}
785
786/// Edit access_token field for Codex
787fn edit_codex_field_access_token(config: &mut CodexConfiguration) -> Result<()> {
788    if let Some(result) = edit_optional_string_field(
789        "访问令牌",
790        config.access_token.as_deref(),
791    )? {
792        config.access_token = result;
793    }
794    Ok(())
795}
796
797/// Edit refresh_token field for Codex
798fn edit_codex_field_refresh_token(config: &mut CodexConfiguration) -> Result<()> {
799    if let Some(result) = edit_optional_string_field(
800        "刷新令牌",
801        config.refresh_token.as_deref(),
802    )? {
803        config.refresh_token = result;
804    }
805    Ok(())
806}
807
808/// Edit account_id field for Codex
809fn edit_codex_field_account_id(config: &mut CodexConfiguration) -> Result<()> {
810    if let Some(result) = edit_optional_string_field(
811        "账户ID",
812        config.account_id.as_deref(),
813    )? {
814        config.account_id = result;
815    }
816    Ok(())
817}
818
819/// Edit last_refresh field for Codex
820fn edit_codex_field_last_refresh(config: &mut CodexConfiguration) -> Result<()> {
821    if let Some(result) = edit_optional_string_field(
822        "上次刷新时间",
823        config.last_refresh.as_deref(),
824    )? {
825        config.last_refresh = result;
826    }
827    Ok(())
828}
829
830/// Save Codex configuration changes to disk and handle alias conflicts
831fn save_codex_configuration_changes(
832    original_alias: &str,
833    new_config: &CodexConfiguration,
834) -> Result<()> {
835    let mut storage = ConfigStorage::load()?;
836
837    if original_alias != new_config.alias_name
838        && storage.get_codex_configuration(&new_config.alias_name).is_some()
839    {
840        println!("\n{}", "别名冲突!".red().bold());
841        println!("配置 '{}' 已存在", new_config.alias_name.yellow());
842        print!("是否覆盖现有配置? (y/N): ");
843        io::stdout().flush()?;
844
845        let mut input = String::new();
846        io::stdin().read_line(&mut input)?;
847        let input = input.trim().to_lowercase();
848
849        if input != "y" && input != "yes" {
850            println!("{}", "编辑已取消".yellow());
851            return Ok(());
852        }
853    }
854
855    storage.update_codex_configuration(original_alias, new_config.clone())?;
856    storage.save()?;
857
858    println!("\n{}", "Codex 配置已成功保存!".green().bold());
859
860    Ok(())
861}
862
863#[cfg(test)]
864mod codex_interactive_tests {
865    use super::*;
866
867    #[test]
868    fn test_format_codex_config_details_apikey() {
869        let config = CodexConfiguration {
870            alias_name: "test".to_string(),
871            auth_mode: "apikey".to_string(),
872            openai_api_key: Some("sk-abc123longkey".to_string()),
873            id_token: None,
874            access_token: None,
875            refresh_token: None,
876            account_id: None,
877            last_refresh: None,
878        };
879
880        let lines = format_codex_config_details(&config, "    ");
881        // Should show auth mode and api key (truncated)
882        assert!(!lines.is_empty());
883        assert!(lines.iter().any(|l| l.contains("apikey")));
884        assert!(lines.iter().any(|l| l.contains("sk-abc12...")));
885    }
886
887    #[test]
888    fn test_format_codex_config_details_chatgpt() {
889        let config = CodexConfiguration {
890            alias_name: "test".to_string(),
891            auth_mode: "chatgpt".to_string(),
892            openai_api_key: None,
893            id_token: Some("id-xyz".to_string()),
894            access_token: Some("at-xyz".to_string()),
895            refresh_token: Some("rt-xyz".to_string()),
896            account_id: Some("acc-123".to_string()),
897            last_refresh: Some("2026-05-16T00:00:00Z".to_string()),
898        };
899
900        let lines = format_codex_config_details(&config, "    ");
901        assert!(lines.iter().any(|l| l.contains("chatgpt")));
902        assert!(lines.iter().any(|l| l.contains("acc-123")));
903        assert!(lines.iter().any(|l| l.contains("2026-05-16")));
904    }
905
906    #[test]
907    fn test_pagination_calculation_codex() {
908        const PAGE_SIZE: usize = 9;
909        assert_eq!(1_usize.div_ceil(PAGE_SIZE), 1);
910        assert_eq!(9_usize.div_ceil(PAGE_SIZE), 1);
911        assert_eq!(10_usize.div_ceil(PAGE_SIZE), 2);
912        assert_eq!(18_usize.div_ceil(PAGE_SIZE), 2);
913        assert_eq!(19_usize.div_ceil(PAGE_SIZE), 3);
914    }
915
916    #[test]
917    fn test_exit_index_no_official_option() {
918        // Without "official" option, exit index == configs.len()
919        let configs_len: usize = 5;
920        let exit_index = configs_len;
921        assert_eq!(exit_index, 5);
922    }
923
924    #[test]
925    fn test_digit_mapping_without_official_offset() {
926        const PAGE_SIZE: usize = 9;
927        let current_page = 0;
928        let start_idx = current_page * PAGE_SIZE;
929
930        // Digit 1 -> actual_config_index 0 (no +1 offset because no official option)
931        let digit = 1;
932        let actual_config_index = start_idx + (digit - 1);
933        assert_eq!(actual_config_index, 0);
934
935        // Digit 5 -> actual_config_index 4
936        let digit = 5;
937        let actual_config_index = start_idx + (digit - 1);
938        assert_eq!(actual_config_index, 4);
939    }
940}