Files
llmx/codex-rs/tui/src/onboarding/auth.rs
ae 5bce369c4d fix: clean up styles & colors and define in styles.md (#2401)
New style guide:

  # Headers, primary, and secondary text
  
- **Headers:** Use `bold`. For markdown with various header levels,
leave in the `#` signs.
  - **Primary text:** Default.
  - **Secondary text:** Use `dim`.
  
  # Foreground colors
  
- **Default:** Most of the time, just use the default foreground color.
`reset` can help get it back.
- **Selection:** Use ANSI `blue`. (Ed & AE want to make this cyan too,
but we'll do that in a followup since it's riskier in different themes.)
  - **User input tips and status indicators:** Use ANSI `cyan`.
  - **Success and additions:** Use ANSI `green`.
  - **Errors, failures and deletions:** Use ANSI `red`.
  - **Codex:** Use ANSI `magenta`.
  
  # Avoid
  
- Avoid custom colors because there's no guarantee that they'll contrast
well or look good on various terminal color themes.
- Avoid ANSI `black`, `white`, `yellow` as foreground colors because the
terminal theme will do a better job. (Use `reset` if you need to in
order to get those.) The exception is if you need contrast rendering
over a manually colored background.
  
  (There are some rules to try to catch this in `clippy.toml`.)

# Testing

Tested in a variety of light and dark color themes in Terminal, iTerm2, and Ghostty.
2025-08-18 08:26:29 -07:00

365 lines
13 KiB
Rust

use codex_login::CLIENT_ID;
use codex_login::ServerOptions;
use codex_login::run_login_server;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use codex_login::AuthMode;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::shimmer_spans;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::thread::JoinHandle;
use super::onboarding_screen::StepState;
// no additional imports
#[derive(Debug)]
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState),
ChatGptSuccessMessage,
ChatGptSuccess,
EnvVarMissing,
EnvVarFound,
}
#[derive(Debug)]
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
pub(crate) struct ContinueInBrowserState {
auth_url: String,
shutdown_flag: Option<Arc<AtomicBool>>,
_login_wait_handle: Option<JoinHandle<()>>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
if let Some(flag) = &self.shutdown_flag {
flag.store(true, Ordering::SeqCst);
}
}
}
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlighted_mode = AuthMode::ChatGPT;
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted_mode = AuthMode::ApiKey;
}
KeyCode::Char('1') => {
self.start_chatgpt_login();
}
KeyCode::Char('2') => self.verify_api_key(),
KeyCode::Enter => match self.sign_in_state {
SignInState::PickMode => match self.highlighted_mode {
AuthMode::ChatGPT => self.start_chatgpt_login(),
AuthMode::ApiKey => self.verify_api_key(),
},
SignInState::EnvVarMissing => self.sign_in_state = SignInState::PickMode,
SignInState::ChatGptSuccessMessage => {
self.sign_in_state = SignInState::ChatGptSuccess
}
_ => {}
},
KeyCode::Esc => {
if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
self.sign_in_state = SignInState::PickMode;
}
}
_ => {}
}
}
}
#[derive(Debug)]
pub(crate) struct AuthModeWidget {
pub event_tx: AppEventSender,
pub highlighted_mode: AuthMode,
pub error: Option<String>,
pub sign_in_state: SignInState,
pub codex_home: PathBuf,
}
impl AuthModeWidget {
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::raw("> "),
Span::styled(
"Sign in with ChatGPT to use Codex as part of your paid plan",
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(
"or connect an API key for usage-based billing",
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
];
let create_mode_item = |idx: usize,
selected_mode: AuthMode,
text: &str,
description: &str|
-> Vec<Line<'static>> {
let is_selected = self.highlighted_mode == selected_mode;
let caret = if is_selected { ">" } else { " " };
let line1 = if is_selected {
Line::from(vec![
format!("{} {}. ", caret, idx + 1).blue().dim(),
text.to_string().blue(),
])
} else {
Line::from(format!(" {}. {text}", idx + 1))
};
let line2 = if is_selected {
Line::from(format!(" {description}"))
.fg(Color::Blue)
.add_modifier(Modifier::DIM)
} else {
Line::from(format!(" {description}"))
.style(Style::default().add_modifier(Modifier::DIM))
};
vec![line1, line2]
};
lines.extend(create_mode_item(
0,
AuthMode::ChatGPT,
"Sign in with ChatGPT",
"Usage included with Plus, Pro, and Team plans",
));
lines.extend(create_mode_item(
1,
AuthMode::ApiKey,
"Provide your own API key",
"Pay for what you use",
));
lines.push(Line::from(""));
lines.push(
Line::from(" Press Enter to continue")
.style(Style::default().add_modifier(Modifier::DIM)),
);
if let Some(err) = &self.error {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
err.as_str(),
Style::default().fg(Color::Red),
)));
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
let mut spans = vec![Span::from("> ")];
// Schedule a follow-up frame to keep the shimmer animation going.
self.event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(
100,
)));
spans.extend(shimmer_spans("Finish signing in via your browser"));
let mut lines = vec![Line::from(spans), Line::from("")];
if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state {
if !state.auth_url.is_empty() {
lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:"));
lines.push(Line::from(vec![
Span::raw(" "),
state.auth_url.as_str().blue().underlined(),
]));
lines.push(Line::from(""));
}
}
lines.push(
Line::from(" Press Esc to cancel").style(Style::default().add_modifier(Modifier::DIM)),
);
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green),
Line::from(""),
Line::from("> Before you start:"),
Line::from(""),
Line::from(" Decide how much autonomy you want to grant Codex"),
Line::from(vec![
Span::raw(" For more details see the "),
Span::styled(
"\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}",
Style::default().add_modifier(Modifier::UNDERLINED),
),
])
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Codex can make mistakes"),
Line::from(" Review the code it writes and commands it runs")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Powered by your ChatGPT account"),
Line::from(vec![
Span::raw(" Uses your plan's rate limits and "),
Span::styled(
"\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}",
Style::default().add_modifier(Modifier::UNDERLINED),
),
])
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Press Enter to continue").fg(Color::Blue),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green)];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![Line::from("✓ Using OPENAI_API_KEY").fg(Color::Green)];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_env_var_missing(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
Line::from(
" To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment",
)
.style(Style::default().fg(Color::Blue)),
Line::from(""),
Line::from(" Press Enter to return")
.style(Style::default().add_modifier(Modifier::DIM)),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn start_chatgpt_login(&mut self) {
self.error = None;
let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string());
let server = run_login_server(opts, None);
match server {
Ok(child) => {
let auth_url = child.auth_url.clone();
let shutdown_flag = child.shutdown_flag.clone();
self.sign_in_state =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url,
shutdown_flag: Some(shutdown_flag),
_login_wait_handle: Some(self.spawn_completion_poller(child)),
});
self.event_tx.send(AppEvent::RequestRedraw);
}
Err(e) => {
self.sign_in_state = SignInState::PickMode;
self.error = Some(e.to_string());
self.event_tx.send(AppEvent::RequestRedraw);
}
}
}
/// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY.
fn verify_api_key(&mut self) {
if std::env::var("OPENAI_API_KEY").is_err() {
self.sign_in_state = SignInState::EnvVarMissing;
} else {
self.sign_in_state = SignInState::EnvVarFound;
}
self.event_tx.send(AppEvent::RequestRedraw);
}
fn spawn_completion_poller(&self, child: codex_login::LoginServer) -> JoinHandle<()> {
let event_tx = self.event_tx.clone();
std::thread::spawn(move || {
if let Ok(()) = child.block_until_done() {
event_tx.send(AppEvent::OnboardingAuthComplete(Ok(())));
} else {
event_tx.send(AppEvent::OnboardingAuthComplete(Err(
"login failed".to_string()
)));
}
})
}
}
impl StepStateProvider for AuthModeWidget {
fn get_step_state(&self) -> StepState {
match &self.sign_in_state {
SignInState::PickMode
| SignInState::EnvVarMissing
| SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
SignInState::ChatGptSuccess | SignInState::EnvVarFound => StepState::Complete,
}
}
}
impl WidgetRef for AuthModeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
match self.sign_in_state {
SignInState::PickMode => {
self.render_pick_mode(area, buf);
}
SignInState::ChatGptContinueInBrowser(_) => {
self.render_continue_in_browser(area, buf);
}
SignInState::ChatGptSuccessMessage => {
self.render_chatgpt_success_message(area, buf);
}
SignInState::ChatGptSuccess => {
self.render_chatgpt_success(area, buf);
}
SignInState::EnvVarMissing => {
self.render_env_var_missing(area, buf);
}
SignInState::EnvVarFound => {
self.render_env_var_found(area, buf);
}
}
}
}