Migrate GitWarning to OnboardingScreen (#1915)

This paves the way to do per-directory approval settings
(https://github.com/openai/codex/pull/1912).

This also lets us pass in a Config/ChatWidgetArgs into onboarding which
can then mutate it and emit the ChatWidgetArgs it wants at the end which
may be modified by the said approval settings.

<img width="1180" height="428" alt="CleanShot 2025-08-06 at 19 30 55"
src="https://github.com/user-attachments/assets/4dcfda42-0f5e-4b6d-a16d-2597109cc31c"
/>
This commit is contained in:
Gabriel Peal
2025-08-06 19:39:07 -07:00
committed by GitHub
parent a5e17cda6b
commit 8a990b5401
13 changed files with 443 additions and 318 deletions

View File

@@ -18,18 +18,23 @@ 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::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::FrameTicker;
use crate::shimmer::shimmer_spans;
use std::path::PathBuf;
use super::onboarding_screen::StepState;
// no additional imports
#[derive(Debug)]
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(#[allow(dead_code)] ContinueInBrowserState),
ChatGptSuccessMessage,
ChatGptSuccess,
EnvVarMissing,
EnvVarFound,
}
#[derive(Debug)]
@@ -38,7 +43,6 @@ pub(crate) struct ContinueInBrowserState {
_login_child: Option<codex_login::SpawnedLogin>,
_frame_ticker: Option<FrameTicker>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
if let Some(child) = &self._login_child {
@@ -52,54 +56,45 @@ impl Drop for ContinueInBrowserState {
}
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.mode = AuthMode::ChatGPT;
KeyEventResult::None
self.highlighted_mode = AuthMode::ChatGPT;
}
KeyCode::Down | KeyCode::Char('j') => {
self.mode = AuthMode::ApiKey;
KeyEventResult::None
self.highlighted_mode = AuthMode::ApiKey;
}
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,
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(),
},
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;
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 event_tx: AppEventSender,
pub highlighted_mode: AuthMode,
pub error: Option<String>,
pub sign_in_state: SignInState,
pub event_tx: AppEventSender,
pub codex_home: PathBuf,
}
@@ -121,7 +116,7 @@ impl AuthModeWidget {
text: &str,
description: &str|
-> Vec<Line<'static>> {
let is_selected = self.mode == selected_mode;
let is_selected = self.highlighted_mode == selected_mode;
let caret = if is_selected { ">" } else { " " };
let line1 = if is_selected {
@@ -192,7 +187,7 @@ impl AuthModeWidget {
.render(area, buf);
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
Line::from("✓ Signed in with your ChatGPT account")
.style(Style::default().fg(SUCCESS_GREEN)),
@@ -219,7 +214,40 @@ impl AuthModeWidget {
.render(area, buf);
}
fn start_chatgpt_login(&mut self) -> KeyEventResult {
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)),
];
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").style(Style::default().fg(SUCCESS_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("✘ OPENAI_API_KEY not found").style(Style::default().fg(Color::Red)),
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;
match codex_login::spawn_login_with_chatgpt(&self.codex_home) {
Ok(child) => {
@@ -230,27 +258,23 @@ impl AuthModeWidget {
_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 {
fn verify_api_key(&mut self) {
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
self.sign_in_state = SignInState::EnvVarMissing;
} else {
KeyEventResult::Continue
self.sign_in_state = SignInState::EnvVarFound;
}
self.event_tx.send(AppEvent::RequestRedraw);
}
fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) {
@@ -299,6 +323,18 @@ impl AuthModeWidget {
}
}
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 {
@@ -308,9 +344,18 @@ impl WidgetRef for AuthModeWidget {
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);
}
}
}
}

View File

@@ -0,0 +1,30 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use crate::app::ChatWidgetArgs;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
/// This doesn't render anything explicitly but serves as a signal that we made it to the end and
/// we should continue to the chat.
pub(crate) struct ContinueToChatWidget {
pub event_tx: AppEventSender,
pub chat_widget_args: ChatWidgetArgs,
}
impl StepStateProvider for ContinueToChatWidget {
fn get_step_state(&self) -> StepState {
StepState::Complete
}
}
impl WidgetRef for &ContinueToChatWidget {
fn render_ref(&self, _area: Rect, _buf: &mut Buffer) {
self.event_tx
.send(AppEvent::OnboardingComplete(self.chat_widget_args.clone()));
}
}

View File

@@ -0,0 +1,126 @@
use std::path::PathBuf;
use codex_core::util::is_inside_git_repo;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
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 crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::colors::LIGHT_BLUE;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
pub(crate) struct GitWarningWidget {
pub event_tx: AppEventSender,
pub cwd: PathBuf,
pub selection: Option<GitWarningSelection>,
pub highlighted: GitWarningSelection,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GitWarningSelection {
Continue,
Exit,
}
impl WidgetRef for &GitWarningWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
Span::raw("> "),
Span::raw("You are running Codex in "),
Span::styled(
self.cwd.to_string_lossy().to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(". This folder is not version controlled."),
]),
Line::from(""),
Line::from(" Do you want to continue?"),
Line::from(""),
];
let create_option =
|idx: usize, option: GitWarningSelection, text: &str| -> Line<'static> {
let is_selected = self.highlighted == option;
if is_selected {
Line::from(vec![
Span::styled(
format!("> {}. ", 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!(" {}. {}", idx + 1, text))
}
};
lines.push(create_option(0, GitWarningSelection::Continue, "Yes"));
lines.push(create_option(1, GitWarningSelection::Exit, "No"));
lines.push(Line::from(""));
lines.push(Line::from(" Press Enter to continue").add_modifier(Modifier::DIM));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
impl KeyboardHandler for GitWarningWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlighted = GitWarningSelection::Continue;
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted = GitWarningSelection::Exit;
}
KeyCode::Char('1') => self.handle_continue(),
KeyCode::Char('2') => self.handle_quit(),
KeyCode::Enter => match self.highlighted {
GitWarningSelection::Continue => self.handle_continue(),
GitWarningSelection::Exit => self.handle_quit(),
},
_ => {}
}
}
}
impl StepStateProvider for GitWarningWidget {
fn get_step_state(&self) -> StepState {
let is_git_repo = is_inside_git_repo(&self.cwd);
match is_git_repo {
true => StepState::Hidden,
false => match self.selection {
Some(_) => StepState::Complete,
None => StepState::InProgress,
},
}
}
}
impl GitWarningWidget {
fn handle_continue(&mut self) {
self.selection = Some(GitWarningSelection::Continue);
}
fn handle_quit(&mut self) {
self.highlighted = GitWarningSelection::Exit;
self.event_tx.send(AppEvent::ExitRequest);
}
}

View File

@@ -1,3 +1,5 @@
mod auth;
mod continue_to_chat;
mod git_warning;
pub mod onboarding_screen;
mod welcome;

View File

@@ -5,26 +5,37 @@ use ratatui::widgets::WidgetRef;
use codex_login::AuthMode;
use crate::app::ChatWidgetArgs;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInState;
use crate::onboarding::continue_to_chat::ContinueToChatWidget;
use crate::onboarding::git_warning::GitWarningSelection;
use crate::onboarding::git_warning::GitWarningWidget;
use crate::onboarding::welcome::WelcomeWidget;
use std::path::PathBuf;
#[allow(clippy::large_enum_variant)]
enum Step {
Welcome(WelcomeWidget),
Auth(AuthModeWidget),
GitWarning(GitWarningWidget),
ContinueToChat(ContinueToChatWidget),
}
pub(crate) trait KeyboardHandler {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult;
fn handle_key_event(&mut self, key_event: KeyEvent);
}
pub(crate) enum KeyEventResult {
Continue,
Quit,
None,
pub(crate) enum StepState {
Hidden,
InProgress,
Complete,
}
pub(crate) trait StepStateProvider {
fn get_step_state(&self) -> StepState;
}
pub(crate) struct OnboardingScreen {
@@ -32,50 +43,113 @@ pub(crate) struct OnboardingScreen {
steps: Vec<Step>,
}
pub(crate) struct OnboardingScreenArgs {
pub event_tx: AppEventSender,
pub chat_widget_args: ChatWidgetArgs,
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub show_login_screen: bool,
pub show_git_warning: bool,
}
impl OnboardingScreen {
pub(crate) fn new(event_tx: AppEventSender, codex_home: PathBuf) -> Self {
let steps: Vec<Step> = vec![
Step::Welcome(WelcomeWidget {}),
Step::Auth(AuthModeWidget {
pub(crate) fn new(args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
event_tx,
chat_widget_args,
codex_home,
cwd,
show_login_screen,
show_git_warning,
} = args;
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
is_logged_in: !show_login_screen,
})];
if show_login_screen {
steps.push(Step::Auth(AuthModeWidget {
event_tx: event_tx.clone(),
mode: AuthMode::ChatGPT,
highlighted_mode: AuthMode::ChatGPT,
error: None,
sign_in_state: SignInState::PickMode,
codex_home,
}),
];
}))
}
if show_git_warning {
steps.push(Step::GitWarning(GitWarningWidget {
event_tx: event_tx.clone(),
cwd,
selection: None,
highlighted: GitWarningSelection::Continue,
}))
}
steps.push(Step::ContinueToChat(ContinueToChatWidget {
event_tx: event_tx.clone(),
chat_widget_args,
}));
// TODO: add git warning.
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() {
pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) {
let current_step = self.current_step_mut();
if let Some(Step::Auth(state)) = current_step {
match result {
Ok(()) => {
state.sign_in_state = SignInState::ChatGptSuccess;
state.sign_in_state = SignInState::ChatGptSuccessMessage;
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
}
}
fn current_steps_mut(&mut self) -> Vec<&mut Step> {
let mut out: Vec<&mut Step> = Vec::new();
for step in self.steps.iter_mut() {
match step.get_step_state() {
StepState::Hidden => continue,
StepState::Complete => out.push(step),
StepState::InProgress => {
out.push(step);
break;
}
}
}
out
}
fn current_steps(&self) -> Vec<&Step> {
let mut out: Vec<&Step> = Vec::new();
for step in self.steps.iter() {
match step.get_step_state() {
StepState::Hidden => continue,
StepState::Complete => out.push(step),
StepState::InProgress => {
out.push(step);
break;
}
}
}
out
}
fn current_step_mut(&mut self) -> Option<&mut Step> {
self.steps
.iter_mut()
.find(|step| matches!(step.get_step_state(), StepState::InProgress))
}
}
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
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
active_step.handle_key_event(key_event);
}
self.event_tx.send(AppEvent::RequestRedraw);
}
}
@@ -109,8 +183,10 @@ impl WidgetRef for &OnboardingScreen {
}
let mut i = 0usize;
while i < self.steps.len() && y < bottom {
let step = &self.steps[i];
let current_steps = self.current_steps();
while i < current_steps.len() && y < bottom {
let step = &current_steps[i];
let max_h = bottom.saturating_sub(y);
if max_h == 0 || width == 0 {
break;
@@ -135,10 +211,22 @@ impl WidgetRef for &OnboardingScreen {
}
impl KeyboardHandler for Step {
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match self {
Step::Welcome(_) => KeyEventResult::None,
Step::Welcome(_) | Step::ContinueToChat(_) => (),
Step::Auth(widget) => widget.handle_key_event(key_event),
Step::GitWarning(widget) => widget.handle_key_event(key_event),
}
}
}
impl StepStateProvider for Step {
fn get_step_state(&self) -> StepState {
match self {
Step::Welcome(w) => w.get_step_state(),
Step::Auth(w) => w.get_step_state(),
Step::GitWarning(w) => w.get_step_state(),
Step::ContinueToChat(w) => w.get_step_state(),
}
}
}
@@ -152,6 +240,12 @@ impl WidgetRef for Step {
Step::Auth(widget) => {
widget.render_ref(area, buf);
}
Step::GitWarning(widget) => {
widget.render_ref(area, buf);
}
Step::ContinueToChat(widget) => {
widget.render_ref(area, buf);
}
}
}
}

View File

@@ -7,7 +7,13 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::WidgetRef;
pub(crate) struct WelcomeWidget {}
use crate::onboarding::onboarding_screen::StepStateProvider;
use super::onboarding_screen::StepState;
pub(crate) struct WelcomeWidget {
pub is_logged_in: bool,
}
impl WidgetRef for &WelcomeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
@@ -21,3 +27,12 @@ impl WidgetRef for &WelcomeWidget {
line.render(area, buf);
}
}
impl StepStateProvider for WelcomeWidget {
fn get_step_state(&self) -> StepState {
match self.is_logged_in {
true => StepState::Hidden,
false => StepState::Complete,
}
}
}