diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 55405736..61512127 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -209,6 +209,9 @@ pub struct Config { /// The active profile name used to derive this `Config` (if any). pub active_profile: Option, + /// Tracks whether the Windows onboarding screen has been acknowledged. + pub windows_wsl_setup_acknowledged: bool, + /// When true, disables burst-paste detection for typed input entirely. /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. @@ -471,6 +474,29 @@ pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Re Ok(()) } +/// Persist the acknowledgement flag for the Windows onboarding screen. +pub fn set_windows_wsl_setup_acknowledged( + codex_home: &Path, + acknowledged: bool, +) -> anyhow::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = match std::fs::read_to_string(config_path.clone()) { + Ok(s) => s.parse::()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e.into()), + }; + + doc["windows_wsl_setup_acknowledged"] = toml_edit::value(acknowledged); + + std::fs::create_dir_all(codex_home)?; + + let tmp_file = NamedTempFile::new_in(codex_home)?; + std::fs::write(tmp_file.path(), doc.to_string())?; + tmp_file.persist(config_path)?; + + Ok(()) +} + fn ensure_profile_table<'a>( doc: &'a mut DocumentMut, profile_name: &str, @@ -737,6 +763,9 @@ pub struct ConfigToml { /// OTEL configuration. pub otel: Option, + + /// Tracks whether the Windows onboarding screen has been acknowledged. + pub windows_wsl_setup_acknowledged: Option, } impl From for UserSavedConfig { @@ -1093,6 +1122,7 @@ impl Config { use_experimental_use_rmcp_client: cfg.experimental_use_rmcp_client.unwrap_or(false), include_view_image_tool, active_profile: active_profile_name, + windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), tui_notifications: cfg .tui @@ -1885,6 +1915,7 @@ model_verbosity = "high" use_experimental_use_rmcp_client: false, include_view_image_tool: true, active_profile: Some("o3".to_string()), + windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -1946,6 +1977,7 @@ model_verbosity = "high" use_experimental_use_rmcp_client: false, include_view_image_tool: true, active_profile: Some("gpt3".to_string()), + windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2022,6 +2054,7 @@ model_verbosity = "high" use_experimental_use_rmcp_client: false, include_view_image_tool: true, active_profile: Some("zdr".to_string()), + windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2084,6 +2117,7 @@ model_verbosity = "high" use_experimental_use_rmcp_client: false, include_view_image_tool: true, active_profile: Some("gpt5".to_string()), + windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 150794fc..b2159612 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -236,7 +236,7 @@ mod tests { #[test] fn drops_from_last_user_only() { - let items = vec![ + let items = [ user_msg("u1"), assistant_msg("a1"), assistant_msg("a2"), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 28d2a3f0..5d7188c0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -80,6 +80,7 @@ pub mod test_backend; mod updates; use crate::onboarding::TrustDirectorySelection; +use crate::onboarding::WSL_INSTRUCTIONS; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; use crate::tui::Tui; @@ -87,6 +88,7 @@ pub use cli::Cli; pub use markdown_render::render_markdown_text; pub use public_widgets::composer_input::ComposerAction; pub use public_widgets::composer_input::ComposerInput; +use std::io::Write as _; // (tests access modules directly within the crate) @@ -364,11 +366,18 @@ async fn run_ratatui_app( let auth_manager = AuthManager::shared(config.codex_home.clone(), false); let login_status = get_login_status(&config); - let should_show_onboarding = - should_show_onboarding(login_status, &config, should_show_trust_screen); + let should_show_windows_wsl_screen = + cfg!(target_os = "windows") && !config.windows_wsl_setup_acknowledged; + let should_show_onboarding = should_show_onboarding( + login_status, + &config, + should_show_trust_screen, + should_show_windows_wsl_screen, + ); if should_show_onboarding { - let directory_trust_decision = run_onboarding_app( + let onboarding_result = run_onboarding_app( OnboardingScreenArgs { + show_windows_wsl_screen: should_show_windows_wsl_screen, show_login_screen: should_show_login_screen(login_status, &config), show_trust_screen: should_show_trust_screen, login_status, @@ -378,7 +387,22 @@ async fn run_ratatui_app( &mut tui, ) .await?; - if let Some(TrustDirectorySelection::Trust) = directory_trust_decision { + if onboarding_result.windows_install_selected { + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + if let Err(err) = writeln!(std::io::stdout(), "{WSL_INSTRUCTIONS}") { + tracing::error!("Failed to write WSL instructions: {err}"); + } + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + }); + } + if should_show_windows_wsl_screen { + config.windows_wsl_setup_acknowledged = true; + } + if let Some(TrustDirectorySelection::Trust) = onboarding_result.directory_trust_decision { config.approval_policy = AskForApproval::OnRequest; config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); } @@ -521,7 +545,12 @@ fn should_show_onboarding( login_status: LoginStatus, config: &Config, show_trust_screen: bool, + show_windows_wsl_screen: bool, ) -> bool { + if show_windows_wsl_screen { + return true; + } + if show_trust_screen { return true; } diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index d4cfd6d1..6c420dae 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -3,3 +3,6 @@ pub mod onboarding_screen; mod trust_directory; pub use trust_directory::TrustDirectorySelection; mod welcome; +mod windows; + +pub(crate) use windows::WSL_INSTRUCTIONS; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 6408f237..69225d97 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -19,6 +19,7 @@ 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; @@ -28,6 +29,7 @@ use std::sync::RwLock; #[allow(clippy::large_enum_variant)] enum Step { + Windows(WindowsSetupWidget), Welcome(WelcomeWidget), Auth(AuthModeWidget), TrustDirectory(TrustDirectoryWidget), @@ -38,6 +40,7 @@ pub(crate) trait KeyboardHandler { fn handle_paste(&mut self, _pasted: String) {} } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum StepState { Hidden, InProgress, @@ -52,9 +55,11 @@ pub(crate) struct OnboardingScreen { request_frame: FrameRequester, steps: Vec, 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, @@ -62,9 +67,15 @@ pub(crate) struct OnboardingScreenArgs { pub config: Config, } +pub(crate) struct OnboardingResult { + pub directory_trust_decision: Option, + 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, @@ -73,10 +84,14 @@ impl OnboardingScreen { } = args; let cwd = config.cwd.clone(); let codex_home = config.codex_home; - let mut steps: Vec = vec![Step::Welcome(WelcomeWidget::new( + let mut steps: Vec = 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 { steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), @@ -110,6 +125,7 @@ impl OnboardingScreen { request_frame: tui.frame_requester(), steps, is_done: false, + windows_install_selected: false, } } @@ -163,6 +179,10 @@ impl OnboardingScreen { }) .flatten() } + + pub fn windows_install_selected(&self) -> bool { + self.windows_install_selected + } } impl KeyboardHandler for OnboardingScreen { @@ -200,6 +220,14 @@ impl KeyboardHandler for OnboardingScreen { } } }; + 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(); } @@ -281,6 +309,7 @@ impl WidgetRef for &OnboardingScreen { 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), @@ -289,6 +318,7 @@ impl KeyboardHandler for Step { 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), @@ -299,6 +329,7 @@ impl KeyboardHandler for Step { 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(), @@ -309,6 +340,9 @@ impl StepStateProvider for Step { 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); } @@ -325,7 +359,7 @@ impl WidgetRef for Step { pub(crate) async fn run_onboarding_app( args: OnboardingScreenArgs, tui: &mut Tui, -) -> Result> { +) -> Result { use tokio_stream::StreamExt; let mut onboarding_screen = OnboardingScreen::new(tui, args); @@ -386,5 +420,8 @@ pub(crate) async fn run_onboarding_app( } } } - Ok(onboarding_screen.directory_trust_decision()) + Ok(OnboardingResult { + directory_trust_decision: onboarding_screen.directory_trust_decision(), + windows_install_selected: onboarding_screen.windows_install_selected(), + }) } diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index ce3f2a3c..1058208b 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -4,6 +4,7 @@ use codex_core::config::set_project_trusted; use codex_core::git_info::resolve_root_git_project_for_trust; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -109,6 +110,10 @@ impl WidgetRef for &TrustDirectoryWidget { 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; @@ -153,3 +158,37 @@ impl TrustDirectoryWidget { self.selection = Some(TrustDirectorySelection::DontTrust); } } + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyEventKind; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn release_event_does_not_change_selection() { + let mut widget = TrustDirectoryWidget { + codex_home: PathBuf::from("."), + 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)); + } +} diff --git a/codex-rs/tui/src/onboarding/windows.rs b/codex-rs/tui/src/onboarding/windows.rs new file mode 100644 index 00000000..a529428a --- /dev/null +++ b/codex-rs/tui/src/onboarding/windows.rs @@ -0,0 +1,190 @@ +use std::path::PathBuf; + +use codex_core::config::set_windows_wsl_setup_acknowledged; +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::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; + +use crate::onboarding::onboarding_screen::KeyboardHandler; +use crate::onboarding::onboarding_screen::StepStateProvider; + +use super::onboarding_screen::StepState; + +pub(crate) const WSL_INSTRUCTIONS: &str = r"Windows Subsystem for Linux (WSL2) is required to run Codex. + +To install WSL2: + 1. Open PowerShell as Administrator and run: wsl --install + 2. Restart your machine if prompted. + 3. Launch the Ubuntu shortcut from the Start menu to complete setup. + +After installation, reopen Codex from a WSL shell."; + +pub(crate) struct WindowsSetupWidget { + pub codex_home: PathBuf, + pub selection: Option, + pub highlighted: WindowsSetupSelection, + pub error: Option, + exit_requested: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowsSetupSelection { + Continue, + Install, +} + +impl WindowsSetupWidget { + pub fn new(codex_home: PathBuf) -> Self { + Self { + codex_home, + selection: None, + highlighted: WindowsSetupSelection::Continue, + error: None, + exit_requested: false, + } + } + + fn handle_continue(&mut self) { + self.highlighted = WindowsSetupSelection::Continue; + match set_windows_wsl_setup_acknowledged(&self.codex_home, true) { + Ok(()) => { + self.selection = Some(WindowsSetupSelection::Continue); + self.exit_requested = false; + self.error = None; + } + Err(err) => { + tracing::error!("Failed to persist Windows onboarding acknowledgement: {err:?}"); + self.error = Some(format!("Failed to update config: {err}")); + self.selection = None; + } + } + } + + fn handle_install(&mut self) { + self.highlighted = WindowsSetupSelection::Install; + self.selection = Some(WindowsSetupSelection::Install); + self.exit_requested = true; + } + + pub fn exit_requested(&self) -> bool { + self.exit_requested + } +} + +impl WidgetRef for &WindowsSetupWidget { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let mut lines: Vec = vec![ + Line::from(vec!["> ".into(), "Codex Windows Support".bold()]), + Line::from(""), + Line::from( + " Codex support for Windows is in progress. Full support for Codex on Windows requires Windows Subsystem for Linux (WSL2).", + ), + Line::from(""), + ]; + + let create_option = + |idx: usize, option: WindowsSetupSelection, text: &str| -> Line<'static> { + if self.highlighted == option { + Line::from(format!("> {}. {text}", idx + 1)).cyan() + } else { + Line::from(format!(" {}. {}", idx + 1, text)) + } + }; + + lines.push(create_option( + 0, + WindowsSetupSelection::Continue, + "Continue anyway", + )); + lines.push(create_option( + 1, + WindowsSetupSelection::Install, + "Exit and install Windows Subsystem for Linux (WSL2)", + )); + lines.push("".into()); + + if let Some(error) = &self.error { + lines.push(Line::from(format!(" {error}")).fg(Color::Red)); + lines.push("".into()); + } + + lines.push(Line::from(vec![" Press Enter to continue".dim()])); + + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); + } +} + +impl KeyboardHandler for WindowsSetupWidget { + 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 = WindowsSetupSelection::Continue; + } + KeyCode::Down | KeyCode::Char('j') => { + self.highlighted = WindowsSetupSelection::Install; + } + KeyCode::Char('1') => self.handle_continue(), + KeyCode::Char('2') => self.handle_install(), + KeyCode::Enter => match self.highlighted { + WindowsSetupSelection::Continue => self.handle_continue(), + WindowsSetupSelection::Install => self.handle_install(), + }, + _ => {} + } + } +} + +impl StepStateProvider for WindowsSetupWidget { + fn get_step_state(&self) -> StepState { + match self.selection { + Some(WindowsSetupSelection::Continue) => StepState::Hidden, + Some(WindowsSetupSelection::Install) => StepState::Complete, + None => StepState::InProgress, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn windows_step_hidden_after_continue() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut widget = WindowsSetupWidget::new(temp_dir.path().to_path_buf()); + + assert_eq!(widget.get_step_state(), StepState::InProgress); + + widget.handle_continue(); + + assert_eq!(widget.get_step_state(), StepState::Hidden); + assert!(!widget.exit_requested()); + } + + #[test] + fn windows_step_complete_after_install_selection() { + let temp_dir = TempDir::new().expect("temp dir"); + let mut widget = WindowsSetupWidget::new(temp_dir.path().to_path_buf()); + + widget.handle_install(); + + assert_eq!(widget.get_step_state(), StepState::Complete); + assert!(widget.exit_requested()); + } +}