Skip to main content

cc_switch/interactive/
codex_interactive.rs

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