Skip to main content

codex_profiles/
ui.rs

1use colored::Colorize;
2use inquire::ui::{Color, RenderConfig, StyleSheet, Styled};
3use std::sync::atomic::{AtomicBool, Ordering};
4use supports_color::Stream;
5
6use crate::has_auth;
7use crate::{Paths, command_name};
8
9static PLAIN: AtomicBool = AtomicBool::new(false);
10
11pub const CANCELLED_MESSAGE: &str = "Cancelled.";
12
13pub fn set_plain(value: bool) {
14    PLAIN.store(value, Ordering::Relaxed);
15}
16
17pub fn is_plain() -> bool {
18    PLAIN.load(Ordering::Relaxed)
19}
20
21pub fn use_color_stdout() -> bool {
22    supports_color(Stream::Stdout)
23}
24
25pub fn use_color_stderr() -> bool {
26    supports_color(Stream::Stderr)
27}
28
29pub fn use_tty_stderr() -> bool {
30    use_color_stderr()
31}
32
33pub fn terminal_width() -> Option<usize> {
34    if is_plain() {
35        return None;
36    }
37    std::env::var("COLUMNS")
38        .ok()
39        .and_then(|value| value.parse::<usize>().ok())
40}
41
42fn supports_color(stream: Stream) -> bool {
43    if is_plain() {
44        return false;
45    }
46    if std::env::var_os("NO_COLOR").is_some() {
47        return false;
48    }
49    supports_color::on(stream).is_some()
50}
51
52pub fn style_text<F>(text: &str, use_color: bool, style: F) -> String
53where
54    F: FnOnce(colored::ColoredString) -> colored::ColoredString,
55{
56    if use_color && !is_plain() {
57        style(text.normal()).to_string()
58    } else {
59        text.to_string()
60    }
61}
62
63pub fn format_cmd(command: &str, use_color: bool) -> String {
64    let text = format!("`{command}`");
65    style_text(&text, use_color, |text| text.yellow().bold())
66}
67
68pub fn format_action(message: &str, use_color: bool) -> String {
69    let text = format!("✅ {message}");
70    style_text(&text, use_color, |text| text.green().bold())
71}
72
73pub fn format_warning(message: &str, use_color: bool) -> String {
74    let text = if is_plain() {
75        format!("WARNING: {message}")
76    } else {
77        format!("Warning: {message}")
78    };
79    style_text(&text, use_color, |text| text.yellow().dimmed().italic())
80}
81
82pub fn format_cancel(use_color: bool) -> String {
83    style_text(CANCELLED_MESSAGE, use_color, |text| text.dimmed().italic())
84}
85
86pub fn format_hint(message: &str, use_color: bool) -> String {
87    if is_plain() {
88        format!("INFO: {message}")
89    } else {
90        let message = format!("\n\n{message}");
91        style_text(&message, use_color, |text| text.italic())
92    }
93}
94
95pub fn format_no_profiles(paths: &Paths, use_color: bool) -> String {
96    let hint = format_save_hint(
97        paths,
98        use_color,
99        "Run {save} to save this profile.",
100        "Run {login} • then {save}.",
101    );
102    format!("No saved profiles. {hint}")
103}
104
105pub fn format_save_before_load(paths: &Paths, use_color: bool) -> String {
106    format_save_hint(
107        paths,
108        use_color,
109        "Run {save} before loading.",
110        "Run {login}, then {save} before loading.",
111    )
112}
113
114pub fn format_unsaved_warning(use_color: bool) -> Vec<String> {
115    let warning = "WARNING: This profile is not saved yet.";
116    let save_line = format!(
117        "Run {} to save this profile.",
118        format_command("save", false)
119    );
120    if !use_color {
121        return vec![warning.to_string(), save_line];
122    }
123    vec![
124        style_text(warning, use_color, |text| text.yellow().dimmed().italic()),
125        style_text(&save_line, use_color, |text| text.dimmed().italic()),
126    ]
127}
128
129pub fn format_list_hint(use_color: bool) -> String {
130    let list = format_command("list", use_color);
131    format_hint(&format!("Run {list} to see saved profiles."), use_color)
132}
133
134pub fn normalize_error(message: &str) -> String {
135    let message = message.strip_prefix("Error: ").unwrap_or(message);
136    if message.contains("codex login") {
137        if message.contains("not found") {
138            return "Not logged in. Run `codex login`.".to_string();
139        }
140        if message.contains("invalid JSON") {
141            return "Auth file is invalid. Run `codex login`.".to_string();
142        }
143        return "Auth is incomplete. Run `codex login`.".to_string();
144    }
145    message.to_string()
146}
147
148pub fn format_error(message: &str) -> String {
149    let normalized = normalize_error(message);
150    let prefix = if use_color_stdout() {
151        "Error:".red().bold().blink().to_string()
152    } else {
153        "Error:".to_string()
154    };
155    format!("{prefix} {normalized}")
156}
157
158pub fn format_profile_display(
159    email: Option<String>,
160    plan: Option<String>,
161    label: Option<String>,
162    is_current: bool,
163    use_color: bool,
164) -> String {
165    let label = label.as_deref();
166    if email
167        .as_deref()
168        .map(|value| value.eq_ignore_ascii_case("Key"))
169        .unwrap_or(false)
170        && plan
171            .as_deref()
172            .map(|value| value.eq_ignore_ascii_case("Key"))
173            .unwrap_or(false)
174    {
175        let badge = format_plan_badge("Key", is_current, use_color);
176        let label_suffix = format_label(label, use_color);
177        return format!("{badge}{label_suffix}");
178    }
179    let label_suffix = format_label(label, use_color);
180    match email {
181        Some(email) => {
182            let plan = plan.unwrap_or_else(|| "Unknown".to_string());
183            let plan_is_free = crate::is_free_plan(Some(&plan));
184            let badge = format_plan_badge(&plan, is_current, use_color);
185            if use_color {
186                let email_badge = format_email_badge(&email, plan_is_free, is_current);
187                format!("{badge}{email_badge}{label_suffix}")
188            } else {
189                format!("{badge} {email}{label_suffix}")
190            }
191        }
192        None => format!("Unknown profile{label_suffix}"),
193    }
194}
195
196pub fn format_entry_header(
197    display: &str,
198    last_used: &str,
199    is_current: bool,
200    use_color: bool,
201) -> String {
202    let mut base = if use_color {
203        display.bold().to_string()
204    } else {
205        display.to_string()
206    };
207    if !is_current && !last_used.is_empty() {
208        base.push_str(&format_last_used_badge(last_used, use_color));
209    }
210    base
211}
212
213fn format_plan_badge(plan: &str, is_current: bool, use_color: bool) -> String {
214    let plan_upper = plan.to_uppercase();
215    let text = format!(" {} ", plan_upper);
216    let plan_is_free = crate::is_free_plan(Some(plan));
217    if use_color {
218        if plan_is_free {
219            text.white().on_bright_red().bold().to_string()
220        } else if is_current {
221            text.white().on_bright_green().bold().to_string()
222        } else {
223            text.white().on_bright_magenta().bold().to_string()
224        }
225    } else {
226        format!("[{plan_upper}]")
227    }
228}
229
230fn format_last_used_badge(last_used: &str, use_color: bool) -> String {
231    if use_color {
232        let text = format!(" {last_used}");
233        style_text(&text, use_color, |text| text.dimmed().italic())
234    } else {
235        format!(" ({last_used})")
236    }
237}
238
239fn format_label(label: Option<&str>, use_color: bool) -> String {
240    match label {
241        Some(value) if use_color => format!(" {value} ").white().on_bright_black().to_string(),
242        Some(value) => format!(" ({value})"),
243        None => String::new(),
244    }
245}
246
247fn format_email_badge(email: &str, plan_is_free: bool, is_current: bool) -> String {
248    if plan_is_free {
249        format!(" {email} ").white().on_red().to_string()
250    } else if is_current {
251        format!(" {email} ").white().on_green().to_string()
252    } else {
253        format!(" {email} ").white().on_magenta().to_string()
254    }
255}
256
257pub fn inquire_select_render_config() -> RenderConfig<'static> {
258    let mut config = if use_color_stderr() {
259        let mut config = RenderConfig::default_colored();
260        config.help_message = StyleSheet::new().with_fg(Color::DarkGrey);
261        config
262    } else {
263        RenderConfig::empty()
264    };
265    config.prompt_prefix = Styled::new("");
266    config.answered_prompt_prefix = Styled::new("");
267    config
268}
269
270pub fn is_inquire_cancel(err: &inquire::error::InquireError) -> bool {
271    matches!(
272        err,
273        inquire::error::InquireError::OperationCanceled
274            | inquire::error::InquireError::OperationInterrupted
275    )
276}
277
278const OUTPUT_INDENT: &str = " ";
279
280pub fn print_output_block(message: &str) {
281    let message = if is_plain() {
282        message.to_string()
283    } else {
284        indent_output(message)
285    };
286    println!("\n{message}\n");
287}
288
289pub fn print_output_block_with_frame(message: &str, separator: &str) {
290    if is_plain() {
291        print_output_block(message);
292        return;
293    }
294    let message = indent_output(message);
295    let separator = indent_output(separator);
296    println!("\n{separator}\n{message}\n{separator}\n");
297}
298
299fn indent_output(message: &str) -> String {
300    message
301        .lines()
302        .map(|line| {
303            if line.is_empty() {
304                String::new()
305            } else {
306                format!("{OUTPUT_INDENT}{line}")
307            }
308        })
309        .collect::<Vec<_>>()
310        .join("\n")
311}
312
313fn format_command(cmd: &str, use_color: bool) -> String {
314    let name = command_name();
315    let full = if cmd.is_empty() {
316        name.to_string()
317    } else {
318        format!("{name} {cmd}")
319    };
320    format_cmd(&full, use_color)
321}
322
323fn format_save_hint(paths: &Paths, use_color: bool, save_only: &str, with_login: &str) -> String {
324    let save = format_command("save", use_color);
325    let message = if has_auth(&paths.auth) {
326        save_only.replace("{save}", &save)
327    } else {
328        let login = format_cmd("codex login", use_color);
329        with_login
330            .replace("{login}", &login)
331            .replace("{save}", &save)
332    };
333    format_hint(&message, use_color)
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::test_utils::{make_paths, set_env_guard, set_plain_guard};
340    use std::fs;
341
342    #[test]
343    fn plain_toggle_affects_output() {
344        {
345            let _plain = set_plain_guard(true);
346            assert!(is_plain());
347            let warning = format_warning("oops", false);
348            assert!(warning.contains("WARNING"));
349        }
350        assert!(!is_plain());
351    }
352
353    #[test]
354    fn terminal_width_parses_columns() {
355        let _plain = set_plain_guard(false);
356        let _env = set_env_guard("COLUMNS", Some("80"));
357        assert_eq!(terminal_width(), Some(80));
358    }
359
360    #[test]
361    fn terminal_width_none_when_plain() {
362        let _plain = set_plain_guard(true);
363        assert_eq!(terminal_width(), None);
364    }
365
366    #[test]
367    fn supports_color_respects_no_color() {
368        let _env = set_env_guard("NO_COLOR", Some("1"));
369        assert!(!use_color_stdout());
370        assert!(!use_color_stderr());
371    }
372
373    #[test]
374    fn format_helpers_basic() {
375        let _plain = set_plain_guard(false);
376        let cmd = format_cmd("codex login", false);
377        assert!(cmd.contains("codex login"));
378        let action = format_action("done", false);
379        assert!(action.contains("done"));
380        let hint = format_hint("hint", false);
381        assert!(hint.contains("hint"));
382        let cancel = format_cancel(false);
383        assert_eq!(cancel, CANCELLED_MESSAGE);
384    }
385
386    #[test]
387    fn format_no_profiles_and_save_before_load() {
388        let dir = tempfile::tempdir().expect("tempdir");
389        let paths = make_paths(dir.path());
390        let msg = format_no_profiles(&paths, false);
391        assert!(msg.contains("No saved profiles"));
392        let msg = format_save_before_load(&paths, false);
393        assert!(msg.contains("save"));
394    }
395
396    #[test]
397    fn format_unsaved_warning_plain() {
398        let lines = format_unsaved_warning(false);
399        assert_eq!(lines.len(), 2);
400        assert!(lines[0].contains("WARNING"));
401    }
402
403    #[test]
404    fn normalize_error_variants() {
405        assert_eq!(
406            normalize_error("Error: Codex auth file not found. Run `codex login` first."),
407            "Not logged in. Run `codex login`."
408        );
409        assert_eq!(
410            normalize_error(
411                "Error: invalid JSON in auth.json: oops. Run `codex login` to regenerate it."
412            ),
413            "Auth file is invalid. Run `codex login`."
414        );
415        assert_eq!(
416            normalize_error(
417                "Error: auth.json is missing tokens.account_id. Run `codex login` to reauthenticate."
418            ),
419            "Auth is incomplete. Run `codex login`."
420        );
421        assert_eq!(normalize_error("other"), "other");
422    }
423
424    #[test]
425    fn format_error_plain() {
426        let _env = set_env_guard("NO_COLOR", Some("1"));
427        let err = format_error("oops");
428        assert!(err.contains("Error:"));
429    }
430
431    #[test]
432    fn format_profile_display_variants() {
433        let key = format_profile_display(
434            Some("Key".to_string()),
435            Some("Key".to_string()),
436            Some("label".to_string()),
437            false,
438            false,
439        );
440        assert!(key.to_lowercase().contains("key"));
441        let display = format_profile_display(
442            Some("me@example.com".to_string()),
443            Some("Free".to_string()),
444            None,
445            true,
446            false,
447        );
448        assert!(display.contains("me@example.com"));
449        let unknown = format_profile_display(None, None, None, false, false);
450        assert!(unknown.contains("Unknown"));
451    }
452
453    #[test]
454    fn format_entry_header_and_separator() {
455        let header = format_entry_header("Display", "1d", false, false);
456        assert!(header.contains("Display"));
457        let indented = super::indent_output("line\n\nline2");
458        assert!(indented.contains("line2"));
459    }
460
461    #[test]
462    fn render_config_and_cancel() {
463        let _env = set_env_guard("NO_COLOR", Some("1"));
464        let config = inquire_select_render_config();
465        assert_eq!(config.prompt_prefix.content, "");
466        let err = inquire::error::InquireError::OperationCanceled;
467        assert!(is_inquire_cancel(&err));
468    }
469
470    #[test]
471    fn print_output_blocks() {
472        let _plain = set_plain_guard(true);
473        print_output_block("hi");
474        print_output_block_with_frame("hi", "-");
475    }
476
477    #[test]
478    fn format_command_uses_name() {
479        let cmd = super::format_command("list", false);
480        assert!(cmd.contains("list"));
481    }
482
483    #[test]
484    fn format_save_hint_with_auth() {
485        let dir = tempfile::tempdir().expect("tempdir");
486        let paths = make_paths(dir.path());
487        fs::write(&paths.auth, "{}").expect("write auth");
488        let hint = super::format_save_hint(&paths, false, "Run {save}", "Run {login} {save}");
489        assert!(hint.contains("save"));
490    }
491}