Files
llmx/codex-rs/tui/src/onboarding/onboarding_screen.rs

439 lines
14 KiB
Rust
Raw Normal View History

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;
Add forced_chatgpt_workspace_id and forced_login_method configuration options (#5303) This PR adds support for configs to specify a forced login method (chatgpt or api) as well as a forced chatgpt account id. This lets enterprises uses [managed configs](https://developers.openai.com/codex/security#managed-configuration) to force all employees to use their company's workspace instead of their own or any other. When a workspace id is set, a query param is sent to the login flow which auto-selects the given workspace or errors if the user isn't a member of it. This PR is large but a large % of it is tests, wiring, and required formatting changes. API login with chatgpt forced <img width="1592" height="116" alt="CleanShot 2025-10-19 at 22 40 04" src="https://github.com/user-attachments/assets/560c6bb4-a20a-4a37-95af-93df39d057dd" /> ChatGPT login with api forced <img width="1018" height="100" alt="CleanShot 2025-10-19 at 22 40 29" src="https://github.com/user-attachments/assets/d010bbbb-9c8d-4227-9eda-e55bf043b4af" /> Onboarding with api forced <img width="892" height="460" alt="CleanShot 2025-10-19 at 22 41 02" src="https://github.com/user-attachments/assets/cc0ed45c-b257-4d62-a32e-6ca7514b5edd" /> Onboarding with ChatGPT forced <img width="1154" height="426" alt="CleanShot 2025-10-19 at 22 41 27" src="https://github.com/user-attachments/assets/41c41417-dc68-4bb4-b3e7-3b7769f7e6a1" /> Logging in with the wrong workspace <img width="2222" height="84" alt="CleanShot 2025-10-19 at 22 42 31" src="https://github.com/user-attachments/assets/0ff4222c-f626-4dd3-b035-0b7fe998a046" />
2025-10-20 08:50:54 -07:00
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::onboarding::windows::WindowsSetupWidget;
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 {
Windows(WindowsSetupWidget),
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,
windows_install_selected: bool,
}
pub(crate) struct OnboardingScreenArgs {
pub show_windows_wsl_screen: bool,
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 windows_install_selected: bool,
}
impl OnboardingScreen {
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
show_windows_wsl_screen,
show_trust_screen,
show_login_screen,
login_status,
auth_manager,
config,
} = args;
let cwd = config.cwd.clone();
Add forced_chatgpt_workspace_id and forced_login_method configuration options (#5303) This PR adds support for configs to specify a forced login method (chatgpt or api) as well as a forced chatgpt account id. This lets enterprises uses [managed configs](https://developers.openai.com/codex/security#managed-configuration) to force all employees to use their company's workspace instead of their own or any other. When a workspace id is set, a query param is sent to the login flow which auto-selects the given workspace or errors if the user isn't a member of it. This PR is large but a large % of it is tests, wiring, and required formatting changes. API login with chatgpt forced <img width="1592" height="116" alt="CleanShot 2025-10-19 at 22 40 04" src="https://github.com/user-attachments/assets/560c6bb4-a20a-4a37-95af-93df39d057dd" /> ChatGPT login with api forced <img width="1018" height="100" alt="CleanShot 2025-10-19 at 22 40 29" src="https://github.com/user-attachments/assets/d010bbbb-9c8d-4227-9eda-e55bf043b4af" /> Onboarding with api forced <img width="892" height="460" alt="CleanShot 2025-10-19 at 22 41 02" src="https://github.com/user-attachments/assets/cc0ed45c-b257-4d62-a32e-6ca7514b5edd" /> Onboarding with ChatGPT forced <img width="1154" height="426" alt="CleanShot 2025-10-19 at 22 41 27" src="https://github.com/user-attachments/assets/41c41417-dc68-4bb4-b3e7-3b7769f7e6a1" /> Logging in with the wrong workspace <img width="2222" height="84" alt="CleanShot 2025-10-19 at 22 42 31" src="https://github.com/user-attachments/assets/0ff4222c-f626-4dd3-b035-0b7fe998a046" />
2025-10-20 08:50:54 -07:00
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();
if show_windows_wsl_screen {
steps.push(Step::Windows(WindowsSetupWidget::new(codex_home.clone())));
}
steps.push(Step::Welcome(WelcomeWidget::new(
!matches!(login_status, LoginStatus::NotAuthenticated),
tui.frame_requester(),
)));
if show_login_screen {
Add forced_chatgpt_workspace_id and forced_login_method configuration options (#5303) This PR adds support for configs to specify a forced login method (chatgpt or api) as well as a forced chatgpt account id. This lets enterprises uses [managed configs](https://developers.openai.com/codex/security#managed-configuration) to force all employees to use their company's workspace instead of their own or any other. When a workspace id is set, a query param is sent to the login flow which auto-selects the given workspace or errors if the user isn't a member of it. This PR is large but a large % of it is tests, wiring, and required formatting changes. API login with chatgpt forced <img width="1592" height="116" alt="CleanShot 2025-10-19 at 22 40 04" src="https://github.com/user-attachments/assets/560c6bb4-a20a-4a37-95af-93df39d057dd" /> ChatGPT login with api forced <img width="1018" height="100" alt="CleanShot 2025-10-19 at 22 40 29" src="https://github.com/user-attachments/assets/d010bbbb-9c8d-4227-9eda-e55bf043b4af" /> Onboarding with api forced <img width="892" height="460" alt="CleanShot 2025-10-19 at 22 41 02" src="https://github.com/user-attachments/assets/cc0ed45c-b257-4d62-a32e-6ca7514b5edd" /> Onboarding with ChatGPT forced <img width="1154" height="426" alt="CleanShot 2025-10-19 at 22 41 27" src="https://github.com/user-attachments/assets/41c41417-dc68-4bb4-b3e7-3b7769f7e6a1" /> Logging in with the wrong workspace <img width="2222" height="84" alt="CleanShot 2025-10-19 at 22 42 31" src="https://github.com/user-attachments/assets/0ff4222c-f626-4dd3-b035-0b7fe998a046" />
2025-10-20 08:50:54 -07:00
let highlighted_mode = match forced_login_method {
Some(ForcedLoginMethod::Api) => AuthMode::ApiKey,
_ => AuthMode::ChatGPT,
};
steps.push(Step::Auth(AuthModeWidget {
request_frame: tui.frame_requester(),
Add forced_chatgpt_workspace_id and forced_login_method configuration options (#5303) This PR adds support for configs to specify a forced login method (chatgpt or api) as well as a forced chatgpt account id. This lets enterprises uses [managed configs](https://developers.openai.com/codex/security#managed-configuration) to force all employees to use their company's workspace instead of their own or any other. When a workspace id is set, a query param is sent to the login flow which auto-selects the given workspace or errors if the user isn't a member of it. This PR is large but a large % of it is tests, wiring, and required formatting changes. API login with chatgpt forced <img width="1592" height="116" alt="CleanShot 2025-10-19 at 22 40 04" src="https://github.com/user-attachments/assets/560c6bb4-a20a-4a37-95af-93df39d057dd" /> ChatGPT login with api forced <img width="1018" height="100" alt="CleanShot 2025-10-19 at 22 40 29" src="https://github.com/user-attachments/assets/d010bbbb-9c8d-4227-9eda-e55bf043b4af" /> Onboarding with api forced <img width="892" height="460" alt="CleanShot 2025-10-19 at 22 41 02" src="https://github.com/user-attachments/assets/cc0ed45c-b257-4d62-a32e-6ca7514b5edd" /> Onboarding with ChatGPT forced <img width="1154" height="426" alt="CleanShot 2025-10-19 at 22 41 27" src="https://github.com/user-attachments/assets/41c41417-dc68-4bb4-b3e7-3b7769f7e6a1" /> Logging in with the wrong workspace <img width="2222" height="84" alt="CleanShot 2025-10-19 at 22 42 31" src="https://github.com/user-attachments/assets/0ff4222c-f626-4dd3-b035-0b7fe998a046" />
2025-10-20 08:50:54 -07:00
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,
Add forced_chatgpt_workspace_id and forced_login_method configuration options (#5303) This PR adds support for configs to specify a forced login method (chatgpt or api) as well as a forced chatgpt account id. This lets enterprises uses [managed configs](https://developers.openai.com/codex/security#managed-configuration) to force all employees to use their company's workspace instead of their own or any other. When a workspace id is set, a query param is sent to the login flow which auto-selects the given workspace or errors if the user isn't a member of it. This PR is large but a large % of it is tests, wiring, and required formatting changes. API login with chatgpt forced <img width="1592" height="116" alt="CleanShot 2025-10-19 at 22 40 04" src="https://github.com/user-attachments/assets/560c6bb4-a20a-4a37-95af-93df39d057dd" /> ChatGPT login with api forced <img width="1018" height="100" alt="CleanShot 2025-10-19 at 22 40 29" src="https://github.com/user-attachments/assets/d010bbbb-9c8d-4227-9eda-e55bf043b4af" /> Onboarding with api forced <img width="892" height="460" alt="CleanShot 2025-10-19 at 22 41 02" src="https://github.com/user-attachments/assets/cc0ed45c-b257-4d62-a32e-6ca7514b5edd" /> Onboarding with ChatGPT forced <img width="1154" height="426" alt="CleanShot 2025-10-19 at 22 41 27" src="https://github.com/user-attachments/assets/41c41417-dc68-4bb4-b3e7-3b7769f7e6a1" /> Logging in with the wrong workspace <img width="2222" height="84" alt="CleanShot 2025-10-19 at 22 42 31" src="https://github.com/user-attachments/assets/0ff4222c-f626-4dd3-b035-0b7fe998a046" />
2025-10-20 08:50:54 -07:00
forced_chatgpt_workspace_id,
forced_login_method,
}))
}
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,
windows_install_selected: 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
}
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 windows_install_selected(&self) -> bool {
self.windows_install_selected
}
}
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,
..
} => {
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);
}
}
};
if self
.steps
.iter()
.any(|step| matches!(step, Step::Windows(widget) if widget.exit_requested()))
{
self.windows_install_selected = true;
self.is_done = true;
}
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::Windows(widget) => widget.handle_key_event(key_event),
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::Windows(_) => {}
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::Windows(w) => w.get_step_state(),
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::Windows(widget) => {
widget.render_ref(area, buf);
}
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(),
windows_install_selected: onboarding_screen.windows_install_selected(),
})
}