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
This commit is contained in:
316
codex-rs/tui/src/onboarding/auth.rs
Normal file
316
codex-rs/tui/src/onboarding/auth.rs
Normal file
@@ -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<codex_login::SpawnedLogin>,
|
||||
_frame_ticker: Option<FrameTicker>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
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<Line> = 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<Line<'static>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
codex-rs/tui/src/onboarding/mod.rs
Normal file
3
codex-rs/tui/src/onboarding/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod auth;
|
||||
pub mod onboarding_screen;
|
||||
mod welcome;
|
||||
157
codex-rs/tui/src/onboarding/onboarding_screen.rs
Normal file
157
codex-rs/tui/src/onboarding/onboarding_screen.rs
Normal file
@@ -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<Step>,
|
||||
}
|
||||
|
||||
impl OnboardingScreen {
|
||||
pub(crate) fn new(event_tx: AppEventSender, codex_home: PathBuf) -> Self {
|
||||
let steps: Vec<Step> = 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<u16> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
codex-rs/tui/src/onboarding/welcome.rs
Normal file
23
codex-rs/tui/src/onboarding/welcome.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user