feat(tui2): copy tui crate and normalize snapshots (#7833)

Introduce a full codex-tui source snapshot under the new codex-tui2
crate so viewport work can be replayed in isolation.

This change copies the entire codex-rs/tui/src tree into
codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep
future diffs vs the original viewport bookmark easy to reason about.

The goal is for codex-tui2 to render identically to the existing TUI
behind the `features.tui2` flag while we gradually port the
viewport/history commits from the joshka/viewport bookmark onto this
forked tree.

While on this baseline change, we also ran the codex-tui2 snapshot test
suite and accepted all insta snapshots for the new crate, so the
snapshot files now use the codex-tui2 naming scheme and encode the
unmodified legacy TUI behavior. This keeps later viewport commits
focused on intentional behavior changes (and their snapshots) rather
than on mechanical snapshot renames.
This commit is contained in:
Josh McKinney authored and GitHub committed 2025-12-10 22:53:46 +00:00
1 parent 321625072a
commit 90f262e9a4
742 files changed
+53558 -18

No files matched your search

+709
View File
@@ -0,0 +1,709 @@
#![allow(clippy::unwrap_used)]
use codex_core::AuthManager;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::auth::read_openai_api_key_from_env;
use codex_login::ServerOptions;
use codex_login::ShutdownHandle;
use codex_login::run_login_server;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
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::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use std::sync::RwLock;
use crate::LoginStatus;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use std::path::PathBuf;
use std::sync::Arc;
use super::onboarding_screen::StepState;
#[derive(Clone)]
pub(crate) enum SignInState {
PickMode,
ChatGptContinueInBrowser(ContinueInBrowserState),
ChatGptSuccessMessage,
ChatGptSuccess,
ApiKeyEntry(ApiKeyInputState),
ApiKeyConfigured,
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
#[derive(Clone, Default)]
pub(crate) struct ApiKeyInputState {
value: String,
prepopulated_from_env: bool,
}
#[derive(Clone)]
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
pub(crate) struct ContinueInBrowserState {
auth_url: String,
shutdown_flag: Option<ShutdownHandle>,
}
impl Drop for ContinueInBrowserState {
fn drop(&mut self) {
if let Some(handle) = &self.shutdown_flag {
handle.shutdown();
}
}
}
impl KeyboardHandler for AuthModeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.handle_api_key_entry_key_event(&key_event) {
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
if self.is_chatgpt_login_allowed() {
self.highlighted_mode = AuthMode::ChatGPT;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if self.is_api_login_allowed() {
self.highlighted_mode = AuthMode::ApiKey;
}
}
KeyCode::Char('1') => {
if self.is_chatgpt_login_allowed() {
self.start_chatgpt_login();
}
}
KeyCode::Char('2') => {
if self.is_api_login_allowed() {
self.start_api_key_entry();
} else {
self.disallow_api_login();
}
}
KeyCode::Enter => {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::PickMode => match self.highlighted_mode {
AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => {
self.start_chatgpt_login();
}
AuthMode::ApiKey if self.is_api_login_allowed() => {
self.start_api_key_entry();
}
AuthMode::ChatGPT => {}
AuthMode::ApiKey => {
self.disallow_api_login();
}
},
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
}
_ => {}
}
}
KeyCode::Esc => {
tracing::info!("Esc pressed");
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
}
}
_ => {}
}
}
fn handle_paste(&mut self, pasted: String) {
let _ = self.handle_api_key_entry_paste(pasted);
}
}
#[derive(Clone)]
pub(crate) struct AuthModeWidget {
pub request_frame: FrameRequester,
pub highlighted_mode: AuthMode,
pub error: Option<String>,
pub sign_in_state: Arc<RwLock<SignInState>>,
pub codex_home: PathBuf,
pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
pub login_status: LoginStatus,
pub auth_manager: Arc<AuthManager>,
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub animations_enabled: bool,
}
impl AuthModeWidget {
fn is_api_login_allowed(&self) -> bool {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt))
}
fn is_chatgpt_login_allowed(&self) -> bool {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Api))
}
fn disallow_api_login(&mut self) {
self.highlighted_mode = AuthMode::ChatGPT;
self.error = Some(API_KEY_DISABLED_MESSAGE.to_string());
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
}
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
let mut lines: Vec<Line> = vec![
Line::from(vec![
" ".into(),
"Sign in with ChatGPT to use Codex as part of your paid plan".into(),
]),
Line::from(vec![
" ".into(),
"or connect an API key for usage-based billing".into(),
]),
"".into(),
];
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).cyan().dim(),
text.to_string().cyan(),
])
} else {
format!(" {}. {text}", idx + 1).into()
};
let line2 = if is_selected {
Line::from(format!(" {description}"))
.fg(Color::Cyan)
.add_modifier(Modifier::DIM)
} else {
Line::from(format!(" {description}"))
.style(Style::default().add_modifier(Modifier::DIM))
};
vec![line1, line2]
};
let chatgpt_description = if self.is_chatgpt_login_allowed() {
"Usage included with Plus, Pro, Team, and Enterprise plans"
} else {
"ChatGPT login is disabled"
};
lines.extend(create_mode_item(
0,
AuthMode::ChatGPT,
"Sign in with ChatGPT",
chatgpt_description,
));
lines.push("".into());
if self.is_api_login_allowed() {
lines.extend(create_mode_item(
1,
AuthMode::ApiKey,
"Provide your own API key",
"Pay for what you use",
));
lines.push("".into());
} else {
lines.push(
" API key login is disabled by this workspace. Sign in with ChatGPT to continue."
.dim()
.into(),
);
lines.push("".into());
}
lines.push(
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
// But leaving this for a future cleanup.
" Press Enter to continue".dim().into(),
);
if let Some(err) = &self.error {
lines.push("".into());
lines.push(err.as_str().red().into());
}
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![" ".into()];
if self.animations_enabled {
// Schedule a follow-up frame to keep the shimmer animation going.
self.request_frame
.schedule_frame_in(std::time::Duration::from_millis(100));
spans.extend(shimmer_spans("Finish signing in via your browser"));
} else {
spans.push("Finish signing in via your browser".into());
}
let mut lines = vec![spans.into(), "".into()];
let sign_in_state = self.sign_in_state.read().unwrap();
if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state
&& !state.auth_url.is_empty()
{
lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into());
lines.push("".into());
lines.push(Line::from(state.auth_url.as_str().cyan().underlined()));
lines.push("".into());
}
lines.push(" Press Esc to cancel".dim().into());
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
"✓ Signed in with your ChatGPT account".fg(Color::Green).into(),
"".into(),
" Before you start:".into(),
"".into(),
" Decide how much autonomy you want to grant Codex".into(),
Line::from(vec![
" For more details see the ".into(),
"\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}".underlined(),
])
.dim(),
"".into(),
" Codex can make mistakes".into(),
" Review the code it writes and commands it runs".dim().into(),
"".into(),
" Powered by your ChatGPT account".into(),
Line::from(vec![
" Uses your plan's rate limits and ".into(),
"\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}".underlined(),
])
.dim(),
"".into(),
" Press Enter to continue".fg(Color::Cyan).into(),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
"✓ Signed in with your ChatGPT account"
.fg(Color::Green)
.into(),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_api_key_configured(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
"✓ API key configured".fg(Color::Green).into(),
"".into(),
" Codex will use usage-based billing with your API key.".into(),
];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
fn render_api_key_entry(&self, area: Rect, buf: &mut Buffer, state: &ApiKeyInputState) {
let [intro_area, input_area, footer_area] = Layout::vertical([
Constraint::Min(4),
Constraint::Length(3),
Constraint::Min(2),
])
.areas(area);
let mut intro_lines: Vec<Line> = vec![
Line::from(vec![
"> ".into(),
"Use your own OpenAI API key for usage-based billing".bold(),
]),
"".into(),
" Paste or type your API key below. It will be stored locally in auth.json.".into(),
"".into(),
];
if state.prepopulated_from_env {
intro_lines.push(" Detected OPENAI_API_KEY environment variable.".into());
intro_lines.push(
" Paste a different key if you prefer to use another account."
.dim()
.into(),
);
intro_lines.push("".into());
}
Paragraph::new(intro_lines)
.wrap(Wrap { trim: false })
.render(intro_area, buf);
let content_line: Line = if state.value.is_empty() {
vec!["Paste or type your API key".dim()].into()
} else {
Line::from(state.value.clone())
};
Paragraph::new(content_line)
.wrap(Wrap { trim: false })
.block(
Block::default()
.title("API key")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan)),
)
.render(input_area, buf);
let mut footer_lines: Vec<Line> = vec![
" Press Enter to save".dim().into(),
" Press Esc to go back".dim().into(),
];
if let Some(error) = &self.error {
footer_lines.push("".into());
footer_lines.push(error.as_str().red().into());
}
Paragraph::new(footer_lines)
.wrap(Wrap { trim: false })
.render(footer_area, buf);
}
fn handle_api_key_entry_key_event(&mut self, key_event: &KeyEvent) -> bool {
let mut should_save: Option<String> = None;
let mut should_request_frame = false;
{
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(state) = &mut *guard {
match key_event.code {
KeyCode::Esc => {
*guard = SignInState::PickMode;
self.error = None;
should_request_frame = true;
}
KeyCode::Enter => {
let trimmed = state.value.trim().to_string();
if trimmed.is_empty() {
self.error = Some("API key cannot be empty".to_string());
should_request_frame = true;
} else {
should_save = Some(trimmed);
}
}
KeyCode::Backspace => {
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
} else {
state.value.pop();
}
self.error = None;
should_request_frame = true;
}
KeyCode::Char(c)
if key_event.kind == KeyEventKind::Press
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
{
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
}
state.value.push(c);
self.error = None;
should_request_frame = true;
}
_ => {}
}
// handled; let guard drop before potential save
} else {
return false;
}
}
if let Some(api_key) = should_save {
self.save_api_key(api_key);
} else if should_request_frame {
self.request_frame.schedule_frame();
}
true
}
fn handle_api_key_entry_paste(&mut self, pasted: String) -> bool {
let trimmed = pasted.trim();
if trimmed.is_empty() {
return false;
}
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(state) = &mut *guard {
if state.prepopulated_from_env {
state.value = trimmed.to_string();
state.prepopulated_from_env = false;
} else {
state.value.push_str(trimmed);
}
self.error = None;
} else {
return false;
}
drop(guard);
self.request_frame.schedule_frame();
true
}
fn start_api_key_entry(&mut self) {
if !self.is_api_login_allowed() {
self.disallow_api_login();
return;
}
self.error = None;
let prefill_from_env = read_openai_api_key_from_env();
let mut guard = self.sign_in_state.write().unwrap();
match &mut *guard {
SignInState::ApiKeyEntry(state) => {
if state.value.is_empty() {
if let Some(prefill) = prefill_from_env {
state.value = prefill;
state.prepopulated_from_env = true;
} else {
state.prepopulated_from_env = false;
}
}
}
_ => {
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
value: prefill_from_env.clone().unwrap_or_default(),
prepopulated_from_env: prefill_from_env.is_some(),
});
}
}
drop(guard);
self.request_frame.schedule_frame();
}
fn save_api_key(&mut self, api_key: String) {
if !self.is_api_login_allowed() {
self.disallow_api_login();
return;
}
match login_with_api_key(
&self.codex_home,
&api_key,
self.cli_auth_credentials_store_mode,
) {
Ok(()) => {
self.error = None;
self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey);
self.auth_manager.reload();
*self.sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured;
}
Err(err) => {
self.error = Some(format!("Failed to save API key: {err}"));
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(existing) = &mut *guard {
if existing.value.is_empty() {
existing.value.push_str(&api_key);
}
existing.prepopulated_from_env = false;
} else {
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
value: api_key,
prepopulated_from_env: false,
});
}
}
}
self.request_frame.schedule_frame();
}
fn start_chatgpt_login(&mut self) {
// If we're already authenticated with ChatGPT, don't start a new login –
// just proceed to the success message flow.
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
return;
}
self.error = None;
let opts = ServerOptions::new(
self.codex_home.clone(),
CLIENT_ID.to_string(),
self.forced_chatgpt_workspace_id.clone(),
self.cli_auth_credentials_store_mode,
);
match run_login_server(opts) {
Ok(child) => {
let sign_in_state = self.sign_in_state.clone();
let request_frame = self.request_frame.clone();
let auth_manager = self.auth_manager.clone();
tokio::spawn(async move {
let auth_url = child.auth_url.clone();
{
*sign_in_state.write().unwrap() =
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
auth_url,
shutdown_flag: Some(child.cancel_handle()),
});
}
request_frame.schedule_frame();
let r = child.block_until_done().await;
match r {
Ok(()) => {
// Force the auth manager to reload the new auth information.
auth_manager.reload();
*sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage;
request_frame.schedule_frame();
}
_ => {
*sign_in_state.write().unwrap() = SignInState::PickMode;
// self.error = Some(e.to_string());
request_frame.schedule_frame();
}
}
});
}
Err(e) => {
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.error = Some(e.to_string());
self.request_frame.schedule_frame();
}
}
}
}
impl StepStateProvider for AuthModeWidget {
fn get_step_state(&self) -> StepState {
let sign_in_state = self.sign_in_state.read().unwrap();
match &*sign_in_state {
SignInState::PickMode
| SignInState::ApiKeyEntry(_)
| SignInState::ChatGptContinueInBrowser(_)
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
}
}
}
impl WidgetRef for AuthModeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let sign_in_state = self.sign_in_state.read().unwrap();
match &*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::ApiKeyEntry(state) => {
self.render_api_key_entry(area, buf, state);
}
SignInState::ApiKeyConfigured => {
self.render_api_key_configured(area, buf);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use codex_core::auth::AuthCredentialsStoreMode;
fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) {
let codex_home = TempDir::new().unwrap();
let codex_home_path = codex_home.path().to_path_buf();
let widget = AuthModeWidget {
request_frame: FrameRequester::test_dummy(),
highlighted_mode: AuthMode::ChatGPT,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home_path.clone(),
cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File,
login_status: LoginStatus::NotAuthenticated,
auth_manager: AuthManager::shared(
codex_home_path,
false,
AuthCredentialsStoreMode::File,
),
forced_chatgpt_workspace_id: None,
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
animations_enabled: true,
};
(widget, codex_home)
}
#[test]
fn api_key_flow_disabled_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt();
widget.start_api_key_entry();
assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE));
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
}
#[test]
fn saving_api_key_is_blocked_when_chatgpt_forced() {
let (mut widget, _tmp) = widget_forced_chatgpt();
widget.save_api_key("sk-test".to_string());
assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE));
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::PickMode
));
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
}
+5
View File
@@ -0,0 +1,5 @@
mod auth;
pub mod onboarding_screen;
mod trust_directory;
pub use trust_directory::TrustDirectorySelection;
mod welcome;
@@ -0,0 +1,430 @@
use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::git_info::get_git_repo_root;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Color;
use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use codex_app_server_protocol::AuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInState;
use crate::onboarding::trust_directory::TrustDirectorySelection;
use crate::onboarding::trust_directory::TrustDirectoryWidget;
use crate::onboarding::welcome::WelcomeWidget;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use color_eyre::eyre::Result;
use std::sync::Arc;
use std::sync::RwLock;
#[allow(clippy::large_enum_variant)]
enum Step {
Welcome(WelcomeWidget),
Auth(AuthModeWidget),
TrustDirectory(TrustDirectoryWidget),
}
pub(crate) trait KeyboardHandler {
fn handle_key_event(&mut self, key_event: KeyEvent);
fn handle_paste(&mut self, _pasted: String) {}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StepState {
Hidden,
InProgress,
Complete,
}
pub(crate) trait StepStateProvider {
fn get_step_state(&self) -> StepState;
}
pub(crate) struct OnboardingScreen {
request_frame: FrameRequester,
steps: Vec<Step>,
is_done: bool,
should_exit: bool,
}
pub(crate) struct OnboardingScreenArgs {
pub show_trust_screen: bool,
pub show_login_screen: bool,
pub login_status: LoginStatus,
pub auth_manager: Arc<AuthManager>,
pub config: Config,
}
pub(crate) struct OnboardingResult {
pub directory_trust_decision: Option<TrustDirectorySelection>,
pub should_exit: bool,
}
impl OnboardingScreen {
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
show_trust_screen,
show_login_screen,
login_status,
auth_manager,
config,
} = args;
let cwd = config.cwd.clone();
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
let forced_login_method = config.forced_login_method;
let codex_home = config.codex_home;
let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode;
let mut steps: Vec<Step> = Vec::new();
steps.push(Step::Welcome(WelcomeWidget::new(
!matches!(login_status, LoginStatus::NotAuthenticated),
tui.frame_requester(),
config.animations,
)));
if show_login_screen {
let highlighted_mode = match forced_login_method {
Some(ForcedLoginMethod::Api) => AuthMode::ApiKey,
_ => AuthMode::ChatGPT,
};
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),
highlighted_mode,
error: None,
sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)),
codex_home: codex_home.clone(),
cli_auth_credentials_store_mode,
login_status,
auth_manager,
forced_chatgpt_workspace_id,
forced_login_method,
animations_enabled: config.animations,
}))
}
let is_git_repo = get_git_repo_root(&cwd).is_some();
let highlighted = if is_git_repo {
TrustDirectorySelection::Trust
} else {
// Default to not trusting the directory if it's not a git repo.
TrustDirectorySelection::DontTrust
};
if show_trust_screen {
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd,
codex_home,
is_git_repo,
selection: None,
highlighted,
error: None,
}))
}
// TODO: add git warning.
Self {
request_frame: tui.frame_requester(),
steps,
is_done: false,
should_exit: false,
}
}
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 is_auth_in_progress(&self) -> bool {
self.steps.iter().any(|step| {
matches!(step, Step::Auth(_)) && matches!(step.get_step_state(), StepState::InProgress)
})
}
pub(crate) fn is_done(&self) -> bool {
self.is_done
|| !self
.steps
.iter()
.any(|step| matches!(step.get_step_state(), StepState::InProgress))
}
pub fn directory_trust_decision(&self) -> Option<TrustDirectorySelection> {
self.steps
.iter()
.find_map(|step| {
if let Step::TrustDirectory(TrustDirectoryWidget { selection, .. }) = step {
Some(*selection)
} else {
None
}
})
.flatten()
}
pub fn should_exit(&self) -> bool {
self.should_exit
}
}
impl KeyboardHandler for OnboardingScreen {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
} => {
if self.is_auth_in_progress() {
// If the user cancels the auth menu, exit the app rather than
// leave the user at a prompt in an unauthed state.
self.should_exit = true;
}
self.is_done = true;
}
_ => {
if let Some(Step::Welcome(widget)) = self
.steps
.iter_mut()
.find(|step| matches!(step, Step::Welcome(_)))
{
widget.handle_key_event(key_event);
}
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
active_step.handle_key_event(key_event);
}
}
};
self.request_frame.schedule_frame();
}
fn handle_paste(&mut self, pasted: String) {
if pasted.is_empty() {
return;
}
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
active_step.handle_paste(pasted);
}
self.request_frame.schedule_frame();
}
}
impl WidgetRef for &OnboardingScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
// 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 cell = &tmp[(xx, yy)];
let has_symbol = !cell.symbol().trim().is_empty();
let has_style = cell.fg != Color::Reset
|| cell.bg != Color::Reset
|| !cell.modifier.is_empty();
if has_symbol || has_style {
any = true;
break;
}
}
if any {
last_non_empty = Some(yy);
}
}
last_non_empty.map(|v| v + 2).unwrap_or(0)
}
let mut i = 0usize;
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;
}
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,
};
Clear.render(target, buf);
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) {
match self {
Step::Welcome(widget) => widget.handle_key_event(key_event),
Step::Auth(widget) => widget.handle_key_event(key_event),
Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
}
}
fn handle_paste(&mut self, pasted: String) {
match self {
Step::Welcome(_) => {}
Step::Auth(widget) => widget.handle_paste(pasted),
Step::TrustDirectory(widget) => widget.handle_paste(pasted),
}
}
}
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::TrustDirectory(w) => w.get_step_state(),
}
}
}
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);
}
Step::TrustDirectory(widget) => {
widget.render_ref(area, buf);
}
}
}
}
pub(crate) async fn run_onboarding_app(
args: OnboardingScreenArgs,
tui: &mut Tui,
) -> Result<OnboardingResult> {
use tokio_stream::StreamExt;
let mut onboarding_screen = OnboardingScreen::new(tui, args);
// One-time guard to fully clear the screen after ChatGPT login success message is shown
let mut did_full_clear_after_success = false;
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&onboarding_screen, frame.area());
})?;
let tui_events = tui.event_stream();
tokio::pin!(tui_events);
while !onboarding_screen.is_done() {
if let Some(event) = tui_events.next().await {
match event {
TuiEvent::Key(key_event) => {
onboarding_screen.handle_key_event(key_event);
}
TuiEvent::Paste(text) => {
onboarding_screen.handle_paste(text);
}
TuiEvent::Draw => {
if !did_full_clear_after_success
&& onboarding_screen.steps.iter().any(|step| {
if let Step::Auth(w) = step {
w.sign_in_state.read().is_ok_and(|g| {
matches!(&*g, super::auth::SignInState::ChatGptSuccessMessage)
})
} else {
false
}
})
{
// Reset any lingering SGR (underline/color) before clearing
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::style::SetAttribute(
ratatui::crossterm::style::Attribute::Reset
),
ratatui::crossterm::style::SetAttribute(
ratatui::crossterm::style::Attribute::NoUnderline
),
ratatui::crossterm::style::SetForegroundColor(
ratatui::crossterm::style::Color::Reset
),
ratatui::crossterm::style::SetBackgroundColor(
ratatui::crossterm::style::Color::Reset
)
);
let _ = tui.terminal.clear();
did_full_clear_after_success = true;
}
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&onboarding_screen, frame.area());
});
}
}
}
}
Ok(OnboardingResult {
directory_trust_decision: onboarding_screen.directory_trust_decision(),
should_exit: onboarding_screen.should_exit(),
})
}
@@ -0,0 +1,14 @@
---
source: tui2/src/onboarding/trust_directory.rs
expression: terminal.backend()
---
> You are running Codex in /workspace/project
Since this folder is version controlled, you may wish to allow Codex
to work in this folder without asking for approval.
› 1. Yes, allow Codex to work in this folder without asking for
approval
2. No, ask me to approve edits and commands
Press enter to continue
@@ -0,0 +1,14 @@
---
source: tui/src/onboarding/trust_directory.rs
expression: terminal.backend()
---
> You are running Codex in /workspace/project
Since this folder is version controlled, you may wish to allow Codex
to work in this folder without asking for approval.
› 1. Yes, allow Codex to work in this folder without asking for
approval
2. No, ask me to approve edits and commands
Press enter to continue
@@ -0,0 +1,238 @@
use std::path::PathBuf;
use codex_core::config::set_project_trust_level;
use codex_core::git_info::resolve_root_git_project_for_trust;
use codex_protocol::config_types::TrustLevel;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::key_hint;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableExt as _;
use crate::selection_list::selection_option_row;
use super::onboarding_screen::StepState;
pub(crate) struct TrustDirectoryWidget {
pub codex_home: PathBuf,
pub cwd: PathBuf,
pub is_git_repo: bool,
pub selection: Option<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TrustDirectorySelection {
Trust,
DontTrust,
}
impl WidgetRef for &TrustDirectoryWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let mut column = ColumnRenderable::new();
column.push(Line::from(vec![
"> ".into(),
"You are running Codex in ".bold(),
self.cwd.to_string_lossy().to_string().into(),
]));
column.push("");
let guidance = if self.is_git_repo {
"Since this folder is version controlled, you may wish to allow Codex to work in this folder without asking for approval."
} else {
"Since this folder is not version controlled, we recommend requiring approval of all edits and commands."
};
column.push(
Paragraph::new(guidance.to_string())
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push("");
let mut options: Vec<(&str, TrustDirectorySelection)> = Vec::new();
if self.is_git_repo {
options.push((
"Yes, allow Codex to work in this folder without asking for approval",
TrustDirectorySelection::Trust,
));
options.push((
"No, ask me to approve edits and commands",
TrustDirectorySelection::DontTrust,
));
} else {
options.push((
"Allow Codex to work in this folder without asking for approval",
TrustDirectorySelection::Trust,
));
options.push((
"Require approval of edits and commands",
TrustDirectorySelection::DontTrust,
));
}
for (idx, (text, selection)) in options.iter().enumerate() {
column.push(selection_option_row(
idx,
text.to_string(),
self.highlighted == *selection,
));
}
column.push("");
if let Some(error) = &self.error {
column.push(
Paragraph::new(error.to_string())
.red()
.wrap(Wrap { trim: true })
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.push("");
}
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" to continue".dim(),
])
.inset(Insets::tlbr(0, 2, 0, 0)),
);
column.render(area, buf);
}
}
impl KeyboardHandler for TrustDirectoryWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlighted = TrustDirectorySelection::Trust;
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted = TrustDirectorySelection::DontTrust;
}
KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(),
KeyCode::Char('2') | KeyCode::Char('n') => self.handle_dont_trust(),
KeyCode::Enter => match self.highlighted {
TrustDirectorySelection::Trust => self.handle_trust(),
TrustDirectorySelection::DontTrust => self.handle_dont_trust(),
},
_ => {}
}
}
}
impl StepStateProvider for TrustDirectoryWidget {
fn get_step_state(&self) -> StepState {
match self.selection {
Some(_) => StepState::Complete,
None => StepState::InProgress,
}
}
}
impl TrustDirectoryWidget {
fn handle_trust(&mut self) {
let target =
resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone());
if let Err(e) = set_project_trust_level(&self.codex_home, &target, TrustLevel::Trusted) {
tracing::error!("Failed to set project trusted: {e:?}");
self.error = Some(format!("Failed to set trust for {}: {e}", target.display()));
}
self.selection = Some(TrustDirectorySelection::Trust);
}
fn handle_dont_trust(&mut self) {
self.highlighted = TrustDirectorySelection::DontTrust;
let target =
resolve_root_git_project_for_trust(&self.cwd).unwrap_or_else(|| self.cwd.clone());
if let Err(e) = set_project_trust_level(&self.codex_home, &target, TrustLevel::Untrusted) {
tracing::error!("Failed to set project untrusted: {e:?}");
self.error = Some(format!(
"Failed to set untrusted for {}: {e}",
target.display()
));
}
self.selection = Some(TrustDirectorySelection::DontTrust);
}
}
#[cfg(test)]
mod tests {
use crate::test_backend::VT100Backend;
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn release_event_does_not_change_selection() {
let codex_home = TempDir::new().expect("temp home");
let mut widget = TrustDirectoryWidget {
codex_home: codex_home.path().to_path_buf(),
cwd: PathBuf::from("."),
is_git_repo: false,
selection: None,
highlighted: TrustDirectorySelection::DontTrust,
error: None,
};
let release = KeyEvent {
kind: KeyEventKind::Release,
..KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
};
widget.handle_key_event(release);
assert_eq!(widget.selection, None);
let press = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
widget.handle_key_event(press);
assert_eq!(widget.selection, Some(TrustDirectorySelection::DontTrust));
}
#[test]
fn renders_snapshot_for_git_repo() {
let codex_home = TempDir::new().expect("temp home");
let widget = TrustDirectoryWidget {
codex_home: codex_home.path().to_path_buf(),
cwd: PathBuf::from("/workspace/project"),
is_git_repo: true,
selection: None,
highlighted: TrustDirectorySelection::Trust,
error: None,
};
let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal");
terminal
.draw(|f| (&widget).render_ref(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(terminal.backend());
}
}
+153
View File
@@ -0,0 +1,153 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::ascii_animation::AsciiAnimation;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::tui::FrameRequester;
use super::onboarding_screen::StepState;
const MIN_ANIMATION_HEIGHT: u16 = 20;
const MIN_ANIMATION_WIDTH: u16 = 60;
pub(crate) struct WelcomeWidget {
pub is_logged_in: bool,
animation: AsciiAnimation,
animations_enabled: bool,
}
impl KeyboardHandler for WelcomeWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if !self.animations_enabled {
return;
}
if key_event.kind == KeyEventKind::Press
&& key_event.code == KeyCode::Char('.')
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
{
tracing::warn!("Welcome background to press '.'");
let _ = self.animation.pick_random_variant();
}
}
}
impl WelcomeWidget {
pub(crate) fn new(
is_logged_in: bool,
request_frame: FrameRequester,
animations_enabled: bool,
) -> Self {
Self {
is_logged_in,
animation: AsciiAnimation::new(request_frame),
animations_enabled,
}
}
}
impl WidgetRef for &WelcomeWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
if self.animations_enabled {
self.animation.schedule_next_frame();
}
// Skip the animation entirely when the viewport is too small so we don't clip frames.
let show_animation =
area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH;
let mut lines: Vec<Line> = Vec::new();
if show_animation && self.animations_enabled {
let frame = self.animation.current_frame();
lines.extend(frame.lines().map(Into::into));
lines.push("".into());
}
lines.push(Line::from(vec![
" ".into(),
"Welcome to ".into(),
"Codex".bold(),
", OpenAI's command-line coding agent".into(),
]));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
impl StepStateProvider for WelcomeWidget {
fn get_step_state(&self) -> StepState {
match self.is_logged_in {
true => StepState::Hidden,
false => StepState::Complete,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
static VARIANT_A: [&str; 1] = ["frame-a"];
static VARIANT_B: [&str; 1] = ["frame-b"];
static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B];
#[test]
fn welcome_renders_animation_on_first_draw() {
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
let mut buf = Buffer::empty(area);
(&widget).render(area, &mut buf);
let mut found = false;
let mut last_non_empty: Option<u16> = None;
for y in 0..area.height {
for x in 0..area.width {
if !buf[(x, y)].symbol().trim().is_empty() {
found = true;
last_non_empty = Some(y);
break;
}
}
}
assert!(found, "expected welcome animation to render characters");
let measured_rows = last_non_empty.map(|v| v + 2).unwrap_or(0);
assert!(
measured_rows >= MIN_ANIMATION_HEIGHT,
"expected measurement to report at least {MIN_ANIMATION_HEIGHT} rows, got {measured_rows}"
);
}
#[test]
fn ctrl_dot_changes_animation_variant() {
let mut widget = WelcomeWidget {
is_logged_in: false,
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
animations_enabled: true,
};
let before = widget.animation.current_frame();
widget.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::CONTROL));
let after = widget.animation.current_frame();
assert_ne!(
before, after,
"expected ctrl+. to switch welcome animation variant"
);
}
}