From 2d5de795aaf38310e7753166fc04f2dff991a7e2 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 6 Aug 2025 15:22:14 -0700 Subject: [PATCH] First pass at a TUI onboarding (#1876) This sets up the scaffolding and basic flow for a TUI onboarding experience. It covers sign in with ChatGPT, env auth, as well as some safety guidance. Next up: 1. Replace the git warning screen 2. Use this to configure default approval/sandbox modes Note the shimmer flashes are from me slicing the video, not jank. https://github.com/user-attachments/assets/0fbe3479-fdde-41f3-87fb-a7a83ab895b8 --- codex-rs/cli/src/main.rs | 4 +- codex-rs/core/src/protocol.rs | 6 + codex-rs/login/src/lib.rs | 57 +++- codex-rs/login/src/login_with_chatgpt.py | 2 +- codex-rs/tui/src/app.rs | 64 +++- codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/colors.rs | 4 + codex-rs/tui/src/lib.rs | 22 +- codex-rs/tui/src/main.rs | 4 +- codex-rs/tui/src/onboarding/auth.rs | 316 ++++++++++++++++++ codex-rs/tui/src/onboarding/mod.rs | 3 + .../tui/src/onboarding/onboarding_screen.rs | 157 +++++++++ codex-rs/tui/src/onboarding/welcome.rs | 23 ++ codex-rs/tui/src/shimmer.rs | 84 +++++ 14 files changed, 724 insertions(+), 25 deletions(-) create mode 100644 codex-rs/tui/src/colors.rs create mode 100644 codex-rs/tui/src/onboarding/auth.rs create mode 100644 codex-rs/tui/src/onboarding/mod.rs create mode 100644 codex-rs/tui/src/onboarding/onboarding_screen.rs create mode 100644 codex-rs/tui/src/onboarding/welcome.rs create mode 100644 codex-rs/tui/src/shimmer.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 27f83121..c43365c7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -121,7 +121,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() let mut tui_cli = cli.interactive; prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides); let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe).await?; - println!("{}", codex_core::protocol::FinalOutput::from(usage)); + if !usage.is_zero() { + println!("{}", codex_core::protocol::FinalOutput::from(usage)); + } } Some(Subcommand::Exec(mut exec_cli)) => { prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides); diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 55000fb6..052806dd 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -429,6 +429,12 @@ pub struct TokenUsage { pub total_tokens: u64, } +impl TokenUsage { + pub fn is_zero(&self) -> bool { + self.total_tokens == 0 + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FinalOutput { pub token_usage: TokenUsage, diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 35f67e71..95bc119e 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -4,6 +4,7 @@ use chrono::Utc; use serde::Deserialize; use serde::Serialize; use std::env; +use std::fs::File; use std::fs::OpenOptions; use std::io::Read; use std::io::Write; @@ -11,6 +12,7 @@ use std::io::Write; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::path::PathBuf; +use std::process::Child; use std::process::Stdio; use std::sync::Arc; use std::sync::Mutex; @@ -183,6 +185,59 @@ fn get_auth_file(codex_home: &Path) -> PathBuf { codex_home.join("auth.json") } +/// Represents a running login subprocess. The child can be killed by holding +/// the mutex and calling `kill()`. +#[derive(Debug, Clone)] +pub struct SpawnedLogin { + pub child: Arc>, + pub stdout: Arc>>, + pub stderr: Arc>>, +} + +/// Spawn the ChatGPT login Python server as a child process and return a handle to its process. +pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result { + let mut cmd = std::process::Command::new("python3"); + cmd.arg("-c") + .arg(SOURCE_FOR_PYTHON_SERVER) + .env("CODEX_HOME", codex_home) + .env("CODEX_CLIENT_ID", CLIENT_ID) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn()?; + + let stdout_buf = Arc::new(Mutex::new(Vec::new())); + let stderr_buf = Arc::new(Mutex::new(Vec::new())); + + if let Some(mut out) = child.stdout.take() { + let buf = stdout_buf.clone(); + std::thread::spawn(move || { + let mut tmp = Vec::new(); + let _ = std::io::copy(&mut out, &mut tmp); + if let Ok(mut b) = buf.lock() { + b.extend_from_slice(&tmp); + } + }); + } + if let Some(mut err) = child.stderr.take() { + let buf = stderr_buf.clone(); + std::thread::spawn(move || { + let mut tmp = Vec::new(); + let _ = std::io::copy(&mut err, &mut tmp); + if let Ok(mut b) = buf.lock() { + b.extend_from_slice(&tmp); + } + }); + } + + Ok(SpawnedLogin { + child: Arc::new(Mutex::new(child)), + stdout: stdout_buf, + stderr: stderr_buf, + }) +} + /// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME /// environment variable set to the provided `codex_home` path. If the /// subprocess exits 0, read the OPENAI_API_KEY property out of @@ -234,7 +289,7 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<( /// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. /// Returns the full AuthDotJson structure after refreshing if necessary. pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { - let mut file = std::fs::File::open(auth_file)?; + let mut file = File::open(auth_file)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; diff --git a/codex-rs/login/src/login_with_chatgpt.py b/codex-rs/login/src/login_with_chatgpt.py index 14ccfa9e..317c9576 100644 --- a/codex-rs/login/src/login_with_chatgpt.py +++ b/codex-rs/login/src/login_with_chatgpt.py @@ -110,7 +110,7 @@ def main() -> None: eprint(f"Failed to open browser: {e}") eprint( - f"If your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}" + f". If your browser did not open, navigate to this URL to authenticate: \n\n{auth_url}" ) # Run the server in the main thread until `shutdown()` is called by the diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2ac550b0..23e12be3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,6 +5,10 @@ use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; +use crate::onboarding::onboarding_screen::KeyEventResult; +use crate::onboarding::onboarding_screen::KeyboardHandler; +use crate::onboarding::onboarding_screen::OnboardingScreen; +use crate::should_show_login_screen; use crate::slash_command::SlashCommand; use crate::tui; use codex_core::config::Config; @@ -35,6 +39,9 @@ const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10); /// Top-level application state: which full-screen view is currently active. #[allow(clippy::large_enum_variant)] enum AppState<'a> { + Onboarding { + screen: OnboardingScreen, + }, /// The main chat UI is visible. Chat { /// Boxed to avoid a large enum variant and reduce the overall size of @@ -42,7 +49,9 @@ enum AppState<'a> { widget: Box>, }, /// The start-up warning that recommends running codex inside a Git repo. - GitWarning { screen: GitWarningScreen }, + GitWarning { + screen: GitWarningScreen, + }, } pub(crate) struct App<'a> { @@ -133,7 +142,20 @@ impl App<'_> { }); } - let (app_state, chat_args) = if show_git_warning { + let show_login_screen = should_show_login_screen(&config); + let (app_state, chat_args) = if show_login_screen { + ( + AppState::Onboarding { + screen: OnboardingScreen::new(app_event_tx.clone(), config.codex_home.clone()), + }, + Some(ChatWidgetArgs { + config: config.clone(), + initial_prompt, + initial_images, + enhanced_keys_supported, + }), + ) + } else if show_git_warning { ( AppState::GitWarning { screen: GitWarningScreen::new(), @@ -232,6 +254,9 @@ impl App<'_> { AppState::Chat { widget } => { widget.on_ctrl_c(); } + AppState::Onboarding { .. } => { + self.app_event_tx.send(AppEvent::ExitRequest); + } AppState::GitWarning { .. } => { // Allow exiting the app with Ctrl+C from the warning screen. self.app_event_tx.send(AppEvent::ExitRequest); @@ -265,6 +290,9 @@ impl App<'_> { self.dispatch_key_event(key_event); } } + AppState::Onboarding { .. } => { + self.app_event_tx.send(AppEvent::ExitRequest); + } AppState::GitWarning { .. } => { self.app_event_tx.send(AppEvent::ExitRequest); } @@ -292,10 +320,12 @@ impl App<'_> { } AppEvent::CodexOp(op) => match &mut self.app_state { AppState::Chat { widget } => widget.submit_op(op), + AppState::Onboarding { .. } => {} AppState::GitWarning { .. } => {} }, AppEvent::LatestLog(line) => match &mut self.app_state { AppState::Chat { widget } => widget.update_latest_log(line), + AppState::Onboarding { .. } => {} AppState::GitWarning { .. } => {} }, AppEvent::DispatchCommand(command) => match command { @@ -392,6 +422,12 @@ impl App<'_> { })); } }, + AppEvent::OnboardingAuthComplete(result) => { + if let AppState::Onboarding { screen } = &mut self.app_state { + // Let the onboarding screen handle success/failure and emit follow-up events. + let _ = screen.on_auth_complete(result); + } + } AppEvent::StartFileSearch(query) => { self.file_search.on_user_query(query); } @@ -410,6 +446,7 @@ impl App<'_> { pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { match &self.app_state { AppState::Chat { widget } => widget.token_usage().clone(), + AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(), AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(), } } @@ -438,6 +475,7 @@ impl App<'_> { let size = terminal.size()?; let desired_height = match &self.app_state { AppState::Chat { widget } => widget.desired_height(size.width), + AppState::Onboarding { .. } => size.height, AppState::GitWarning { .. } => size.height, }; @@ -468,6 +506,7 @@ impl App<'_> { } frame.render_widget_ref(&**widget, frame.area()) } + AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()), AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()), })?; Ok(()) @@ -480,6 +519,25 @@ impl App<'_> { AppState::Chat { widget } => { widget.handle_key_event(key_event); } + AppState::Onboarding { screen } => match screen.handle_key_event(key_event) { + KeyEventResult::Continue => { + self.app_state = AppState::Chat { + widget: Box::new(ChatWidget::new( + self.config.clone(), + self.app_event_tx.clone(), + None, + Vec::new(), + self.enhanced_keys_supported, + )), + }; + } + KeyEventResult::Quit => { + self.app_event_tx.send(AppEvent::ExitRequest); + } + KeyEventResult::None => { + // do nothing + } + }, AppState::GitWarning { screen } => match screen.handle_key_event(key_event) { GitWarningOutcome::Continue => { // User accepted – switch to chat view. @@ -511,6 +569,7 @@ impl App<'_> { fn dispatch_paste_event(&mut self, pasted: String) { match &mut self.app_state { AppState::Chat { widget } => widget.handle_paste(pasted), + AppState::Onboarding { .. } => {} AppState::GitWarning { .. } => {} } } @@ -518,6 +577,7 @@ impl App<'_> { fn dispatch_codex_event(&mut self, event: Event) { match &mut self.app_state { AppState::Chat { widget } => widget.handle_codex_event(event), + AppState::Onboarding { .. } => {} AppState::GitWarning { .. } => {} } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 77a600d3..7df7761a 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -48,4 +48,7 @@ pub(crate) enum AppEvent { }, InsertHistory(Vec>), + + /// Onboarding: result of login_with_chatgpt. + OnboardingAuthComplete(Result<(), String>), } diff --git a/codex-rs/tui/src/colors.rs b/codex-rs/tui/src/colors.rs new file mode 100644 index 00000000..0ba386df --- /dev/null +++ b/codex-rs/tui/src/colors.rs @@ -0,0 +1,4 @@ +use ratatui::style::Color; + +pub(crate) const LIGHT_BLUE: Color = Color::Rgb(134, 238, 255); +pub(crate) const SUCCESS_GREEN: Color = Color::Rgb(169, 230, 158); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0228a568..e65083bf 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -13,7 +13,6 @@ use codex_login::load_auth; use codex_ollama::DEFAULT_OSS_MODEL; use log_layer::TuiLogLayer; use std::fs::OpenOptions; -use std::io::Write; use std::path::PathBuf; use tracing::error; use tracing_appender::non_blocking; @@ -27,6 +26,7 @@ mod bottom_pane; mod chatwidget; mod citation_regex; mod cli; +mod colors; pub mod custom_terminal; mod exec_command; mod file_search; @@ -37,6 +37,8 @@ pub mod insert_history; pub mod live_wrap; mod log_layer; mod markdown; +pub mod onboarding; +mod shimmer; mod slash_command; mod status_indicator_widget; mod text_block; @@ -204,24 +206,6 @@ pub async fn run_main( eprintln!(""); } - let show_login_screen = should_show_login_screen(&config); - if show_login_screen { - std::io::stdout() - .write_all(b"No API key detected.\nLogin with your ChatGPT account? [Yn] ")?; - std::io::stdout().flush()?; - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - let trimmed = input.trim(); - if !(trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y")) { - std::process::exit(1); - } - // Spawn a task to run the login command. - // Block until the login command is finished. - codex_login::login_with_chatgpt(&config.codex_home, false).await?; - - std::io::stdout().write_all(b"Login successful.\n")?; - } - // Determine whether we need to display the "not a git repo" warning // modal. The flag is shown when the current working directory is *not* // inside a Git repository **and** the user did *not* pass the diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 209febf0..2dbd797d 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -22,7 +22,9 @@ fn main() -> anyhow::Result<()> { .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); let usage = run_main(inner, codex_linux_sandbox_exe).await?; - println!("{}", codex_core::protocol::FinalOutput::from(usage)); + if !usage.is_zero() { + println!("{}", codex_core::protocol::FinalOutput::from(usage)); + } Ok(()) }) } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs new file mode 100644 index 00000000..834c3681 --- /dev/null +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -0,0 +1,316 @@ +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::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::colors::LIGHT_BLUE; +use crate::colors::SUCCESS_GREEN; +use crate::onboarding::onboarding_screen::KeyEventResult; +use crate::onboarding::onboarding_screen::KeyboardHandler; +use crate::shimmer::FrameTicker; +use crate::shimmer::shimmer_spans; +use std::path::PathBuf; +// no additional imports + +#[derive(Debug)] +pub(crate) enum SignInState { + PickMode, + ChatGptContinueInBrowser(#[allow(dead_code)] ContinueInBrowserState), + ChatGptSuccess, +} + +#[derive(Debug)] +/// Used to manage the lifecycle of SpawnedLogin and FrameTicker and ensure they get cleaned up. +pub(crate) struct ContinueInBrowserState { + _login_child: Option, + _frame_ticker: Option, +} + +impl Drop for ContinueInBrowserState { + fn drop(&mut self) { + if let Some(child) = &self._login_child { + if let Ok(mut locked) = child.child.lock() { + // Best-effort terminate and reap the child to avoid zombies. + let _ = locked.kill(); + let _ = locked.wait(); + } + } + } +} + +impl KeyboardHandler for AuthModeWidget { + fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult { + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => { + self.mode = AuthMode::ChatGPT; + KeyEventResult::None + } + KeyCode::Down | KeyCode::Char('j') => { + self.mode = AuthMode::ApiKey; + KeyEventResult::None + } + KeyCode::Char('1') => { + self.mode = AuthMode::ChatGPT; + self.start_chatgpt_login(); + KeyEventResult::None + } + KeyCode::Char('2') => { + self.mode = AuthMode::ApiKey; + self.verify_api_key() + } + KeyCode::Enter => match self.mode { + AuthMode::ChatGPT => match &self.sign_in_state { + SignInState::PickMode => self.start_chatgpt_login(), + SignInState::ChatGptContinueInBrowser(_) => KeyEventResult::None, + SignInState::ChatGptSuccess => KeyEventResult::Continue, + }, + AuthMode::ApiKey => self.verify_api_key(), + }, + KeyCode::Esc => { + if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { + self.sign_in_state = SignInState::PickMode; + self.event_tx.send(AppEvent::RequestRedraw); + KeyEventResult::None + } else { + KeyEventResult::Quit + } + } + KeyCode::Char('q') => KeyEventResult::Quit, + _ => KeyEventResult::None, + } + } +} + +#[derive(Debug)] +pub(crate) struct AuthModeWidget { + pub mode: AuthMode, + pub error: Option, + pub sign_in_state: SignInState, + pub event_tx: AppEventSender, + pub codex_home: PathBuf, +} + +impl AuthModeWidget { + fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) { + let mut lines: Vec = vec![ + Line::from(vec![ + Span::raw("> "), + Span::styled( + "Sign in with your ChatGPT account?", + Style::default().add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + ]; + + let create_mode_item = |idx: usize, + selected_mode: AuthMode, + text: &str, + description: &str| + -> Vec> { + let is_selected = self.mode == selected_mode; + let caret = if is_selected { ">" } else { " " }; + + let line1 = if is_selected { + Line::from(vec![ + Span::styled( + format!("{} {}. ", caret, idx + 1), + Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM), + ), + Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)), + ]) + } else { + Line::from(format!(" {}. {text}", idx + 1)) + }; + + let line2 = if is_selected { + Line::from(format!(" {description}")) + .style(Style::default().fg(LIGHT_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 or create a new account", + "Leverages your plan, starting at $20 a month for Plus", + )); + lines.extend(create_mode_item( + 1, + AuthMode::ApiKey, + "Provide your own API key", + "Pay only 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 idx = self.current_frame(); + let mut spans = vec![Span::from("> ")]; + spans.extend(shimmer_spans("Finish signing in via your browser", idx)); + let lines = vec![ + Line::from(spans), + Line::from(""), + Line::from(" Press Escape to cancel") + .style(Style::default().add_modifier(Modifier::DIM)), + ]; + 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") + .style(Style::default().fg(SUCCESS_GREEN)), + Line::from(""), + Line::from("> Before you start:"), + Line::from(""), + Line::from(" Codex can make mistakes"), + Line::from(" Check important info") + .style(Style::default().add_modifier(Modifier::DIM)), + Line::from(""), + Line::from(" Due to prompt injection risks, only use it with code you trust"), + Line::from(" For more details see https://github.com/openai/codex") + .style(Style::default().add_modifier(Modifier::DIM)), + Line::from(""), + Line::from(" Powered by your ChatGPT account"), + Line::from(" Uses your plan's rate limits and training data preferences") + .style(Style::default().add_modifier(Modifier::DIM)), + Line::from(""), + Line::from(" Press Enter to continue").style(Style::default().fg(LIGHT_BLUE)), + ]; + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } + + fn start_chatgpt_login(&mut self) -> KeyEventResult { + self.error = None; + match codex_login::spawn_login_with_chatgpt(&self.codex_home) { + Ok(child) => { + self.spawn_completion_poller(child.clone()); + self.sign_in_state = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + _login_child: Some(child), + _frame_ticker: Some(FrameTicker::new(self.event_tx.clone())), + }); + self.event_tx.send(AppEvent::RequestRedraw); + KeyEventResult::None + } + Err(e) => { + self.sign_in_state = SignInState::PickMode; + self.error = Some(e.to_string()); + self.event_tx.send(AppEvent::RequestRedraw); + KeyEventResult::None + } + } + } + + /// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY. + fn verify_api_key(&mut self) -> KeyEventResult { + if std::env::var("OPENAI_API_KEY").is_err() { + self.error = + Some("Set OPENAI_API_KEY in your environment. Learn more: https://platform.openai.com/docs/libraries".to_string()); + self.event_tx.send(AppEvent::RequestRedraw); + KeyEventResult::None + } else { + KeyEventResult::Continue + } + } + + fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) { + let child_arc = child.child.clone(); + let stderr_buf = child.stderr.clone(); + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + loop { + let done = { + if let Ok(mut locked) = child_arc.lock() { + match locked.try_wait() { + Ok(Some(status)) => Some(status.success()), + Ok(None) => None, + Err(_) => Some(false), + } + } else { + Some(false) + } + }; + if let Some(success) = done { + if success { + event_tx.send(AppEvent::OnboardingAuthComplete(Ok(()))); + } else { + let err = stderr_buf + .lock() + .ok() + .and_then(|b| String::from_utf8(b.clone()).ok()) + .unwrap_or_else(|| "login_with_chatgpt subprocess failed".to_string()); + event_tx.send(AppEvent::OnboardingAuthComplete(Err(err))); + } + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + }); + } + + fn current_frame(&self) -> usize { + // Derive frame index from wall-clock time to avoid storing animation state. + // 100ms per frame to match the previous ticker cadence. + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + (now_ms / 100) as usize + } +} + +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::ChatGptSuccess => { + self.render_chatgpt_success(area, buf); + } + } + } +} diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs new file mode 100644 index 00000000..42d3ac81 --- /dev/null +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -0,0 +1,3 @@ +mod auth; +pub mod onboarding_screen; +mod welcome; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs new file mode 100644 index 00000000..e2548bac --- /dev/null +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -0,0 +1,157 @@ +use crossterm::event::KeyEvent; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use codex_login::AuthMode; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::onboarding::auth::AuthModeWidget; +use crate::onboarding::auth::SignInState; +use crate::onboarding::welcome::WelcomeWidget; +use std::path::PathBuf; + +enum Step { + Welcome(WelcomeWidget), + Auth(AuthModeWidget), +} + +pub(crate) trait KeyboardHandler { + fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult; +} + +pub(crate) enum KeyEventResult { + Continue, + Quit, + None, +} + +pub(crate) struct OnboardingScreen { + event_tx: AppEventSender, + steps: Vec, +} + +impl OnboardingScreen { + pub(crate) fn new(event_tx: AppEventSender, codex_home: PathBuf) -> Self { + let steps: Vec = vec![ + Step::Welcome(WelcomeWidget {}), + Step::Auth(AuthModeWidget { + event_tx: event_tx.clone(), + mode: AuthMode::ChatGPT, + error: None, + sign_in_state: SignInState::PickMode, + codex_home, + }), + ]; + Self { event_tx, steps } + } + + pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) -> KeyEventResult { + if let Some(Step::Auth(state)) = self.steps.last_mut() { + match result { + Ok(()) => { + state.sign_in_state = SignInState::ChatGptSuccess; + self.event_tx.send(AppEvent::RequestRedraw); + KeyEventResult::None + } + Err(e) => { + state.sign_in_state = SignInState::PickMode; + state.error = Some(e); + self.event_tx.send(AppEvent::RequestRedraw); + KeyEventResult::None + } + } + } else { + KeyEventResult::None + } + } +} + +impl KeyboardHandler for OnboardingScreen { + fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult { + if let Some(last_step) = self.steps.last_mut() { + self.event_tx.send(AppEvent::RequestRedraw); + last_step.handle_key_event(key_event) + } else { + KeyEventResult::None + } + } +} + +impl WidgetRef for &OnboardingScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + // Render steps top-to-bottom, measuring each step's height dynamically. + let mut y = area.y; + let bottom = area.y.saturating_add(area.height); + let width = area.width; + + // Helper to scan a temporary buffer and return number of used rows. + fn used_rows(tmp: &Buffer, width: u16, height: u16) -> u16 { + if width == 0 || height == 0 { + return 0; + } + let mut last_non_empty: Option = None; + for yy in 0..height { + let mut any = false; + for xx in 0..width { + let sym = tmp[(xx, yy)].symbol(); + if !sym.trim().is_empty() { + any = true; + break; + } + } + if any { + last_non_empty = Some(yy); + } + } + last_non_empty.map(|v| v + 2).unwrap_or(0) + } + + let mut i = 0usize; + while i < self.steps.len() && y < bottom { + let step = &self.steps[i]; + let max_h = bottom.saturating_sub(y); + if max_h == 0 || width == 0 { + break; + } + let scratch_area = Rect::new(0, 0, width, max_h); + let mut scratch = Buffer::empty(scratch_area); + step.render_ref(scratch_area, &mut scratch); + let h = used_rows(&scratch, width, max_h).min(max_h); + if h > 0 { + let target = Rect { + x: area.x, + y, + width, + height: h, + }; + step.render_ref(target, buf); + y = y.saturating_add(h); + } + i += 1; + } + } +} + +impl KeyboardHandler for Step { + fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult { + match self { + Step::Welcome(_) => KeyEventResult::None, + Step::Auth(widget) => widget.handle_key_event(key_event), + } + } +} + +impl WidgetRef for Step { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + match self { + Step::Welcome(widget) => { + widget.render_ref(area, buf); + } + Step::Auth(widget) => { + widget.render_ref(area, buf); + } + } + } +} diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs new file mode 100644 index 00000000..e00e3004 --- /dev/null +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -0,0 +1,23 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::WidgetRef; + +pub(crate) struct WelcomeWidget {} + +impl WidgetRef for &WelcomeWidget { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let line = Line::from(vec![ + Span::raw("> "), + Span::styled( + "Welcome to Codex, OpenAI's coding agent that runs in your terminal", + Style::default().add_modifier(Modifier::BOLD), + ), + ]); + line.render(area, buf); + } +} diff --git a/codex-rs/tui/src/shimmer.rs b/codex-rs/tui/src/shimmer.rs new file mode 100644 index 00000000..9d28b732 --- /dev/null +++ b/codex-rs/tui/src/shimmer.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use ratatui::style::Color; +use ratatui::style::Modifier; +use ratatui::style::Style; +use ratatui::text::Span; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; + +#[derive(Debug)] +pub(crate) struct FrameTicker { + running: Arc, +} + +impl FrameTicker { + pub(crate) fn new(app_event_tx: AppEventSender) -> Self { + let running = Arc::new(AtomicBool::new(true)); + let running_clone = running.clone(); + let app_event_tx_clone = app_event_tx.clone(); + std::thread::spawn(move || { + while running_clone.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(100)); + app_event_tx_clone.send(AppEvent::RequestRedraw); + } + }); + Self { running } + } +} + +impl Drop for FrameTicker { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + } +} + +pub(crate) fn shimmer_spans(text: &str, frame_idx: usize) -> Vec> { + let chars: Vec = text.chars().collect(); + let padding = 10usize; + let period = chars.len() + padding * 2; + let pos = frame_idx % period; + let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout) + .map(|level| level.has_16m) + .unwrap_or(false); + let band_half_width = 6.0; + + let mut spans: Vec> = Vec::with_capacity(chars.len()); + for (i, ch) in chars.iter().enumerate() { + let i_pos = i as isize + padding as isize; + let pos = pos as isize; + let dist = (i_pos - pos).abs() as f32; + + let t = if dist <= band_half_width { + let x = std::f32::consts::PI * (dist / band_half_width); + 0.5 * (1.0 + x.cos()) + } else { + 0.0 + }; + let brightness = 0.4 + 0.6 * t; + let level = (brightness * 255.0).clamp(0.0, 255.0) as u8; + let style = if has_true_color { + Style::default() + .fg(Color::Rgb(level, level, level)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(color_for_level(level)) + }; + spans.push(Span::styled(ch.to_string(), style)); + } + spans +} + +fn color_for_level(level: u8) -> Color { + if level < 128 { + Color::DarkGray + } else if level < 192 { + Color::Gray + } else { + Color::White + } +}