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