diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 7905f48451..52a27567f1 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -129,8 +129,8 @@ pub(crate) struct ApiKeyInputState { #[derive(Clone)] /// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up. pub(crate) struct ContinueInBrowserState { - login_id: String, - auth_url: String, + pub(super) login_id: String, + pub(super) auth_url: String, } #[derive(Clone)] @@ -575,7 +575,14 @@ impl AuthModeWidget { let mut lines = vec![spans.into(), "".into()]; let auth_url = if !state.auth_url.is_empty() { - lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); + lines.push(Line::from(vec![ + " If the link doesn't open automatically, press ".into(), + keys::COPY_LINK[0].into(), + " to copy it:".into(), + ])); + if let Some(message) = self.error_message() { + lines.push(format!(" {message}").dim().into()); + } lines.push("".into()); lines.push(Line::from(vec![ " ".into(), diff --git a/codex-rs/tui/src/onboarding/keys.rs b/codex-rs/tui/src/onboarding/keys.rs index d573f47d33..ff7eacac44 100644 --- a/codex-rs/tui/src/onboarding/keys.rs +++ b/codex-rs/tui/src/onboarding/keys.rs @@ -26,6 +26,7 @@ pub(crate) const SELECT_THIRD: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char( pub(crate) const SELECT_FOURTH: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char('4'))]; pub(crate) const CONFIRM: [KeyBinding; 1] = [key_hint::plain(KeyCode::Enter)]; pub(crate) const CANCEL: [KeyBinding; 1] = [key_hint::plain(KeyCode::Esc)]; +pub(crate) const COPY_LINK: [KeyBinding; 1] = [key_hint::plain(KeyCode::Char('c'))]; pub(crate) const QUIT: [KeyBinding; 3] = [ key_hint::plain(KeyCode::Char('q')), key_hint::ctrl(KeyCode::Char('c')), diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 6ebdc8f07e..415c7d6376 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -33,6 +33,8 @@ use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::app_server_session::AppServerSession; +use crate::clipboard_copy::CopyFormat; +use crate::clipboard_copy::CopyStatus; use crate::config_update::RemoteProjectTrust; use crate::config_update::format_config_error; use crate::config_update::replace_config_value; @@ -48,6 +50,7 @@ use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; use crate::onboarding::welcome::WelcomeWidget; use crate::tui::FrameRequester; +use crate::tui::OverlayInput; use crate::tui::Tui; use crate::tui::TuiEvent; use color_eyre::eyre::Result; @@ -543,6 +546,30 @@ pub(crate) async fn run_onboarding_app( } async fn run_onboarding_screen( + onboarding_screen: OnboardingScreen, + app_server_request_handle: Option, + app_server: Option<&mut AppServerSession>, + tui: &mut Tui, +) -> Result { + // Let the terminal select and copy login URLs and device codes, even in fullscreen mode. + let previous_input = tui.overlay_input; + let result = match tui.set_overlay_input(OverlayInput::Onboarding) { + Ok(()) => { + run_onboarding_screen_inner( + onboarding_screen, + app_server_request_handle, + app_server, + tui, + ) + .await + } + Err(error) => Err(error.into()), + }; + let restore = tui.set_overlay_input(previous_input); + result.and_then(|result| restore.map(|()| result).map_err(Into::into)) +} + +async fn run_onboarding_screen_inner( mut onboarding_screen: OnboardingScreen, app_server_request_handle: Option, mut app_server: Option<&mut AppServerSession>, @@ -552,6 +579,7 @@ async fn run_onboarding_screen( let mut directory_trust_persisted = false; // One-time guard to fully clear the screen after ChatGPT login success message is shown let mut did_full_clear_after_success = false; + let mut pending_copy: Option<(u64, String)> = None; tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&onboarding_screen, frame.area()); @@ -569,7 +597,36 @@ async fn run_onboarding_screen( tui.screen_size_for_event(&event)?; match event { TuiEvent::Key(key_event) => { - onboarding_screen.handle_key_event(key_event); + let copy_link = if key_event.kind == KeyEventKind::Press + && keys::COPY_LINK.is_pressed(key_event) + && let Some(Step::Auth(widget)) = onboarding_screen.current_steps().last() + && let Ok(state) = widget.sign_in_state.read() + && let SignInState::ChatGptContinueInBrowser(state) = &*state + && !state.auth_url.is_empty() + { + Some((state.login_id.clone(), Arc::::from(state.auth_url.as_str()))) + } else { + None + }; + if let Some((login_id, url)) = copy_link { + let result = tui.clipboard.copy(url, CopyFormat::PlainText, tui.frame_requester()); + let message = match result { + Ok(CopyStatus::Pending(id)) => { + pending_copy = Some((id, login_id)); + "Copying link…".to_string() + } + Ok(CopyStatus::Busy) => "Clipboard busy; try again".to_string(), + Ok(CopyStatus::Confirmed) => "Copied link to clipboard".to_string(), + Ok(CopyStatus::Unconfirmed) => "Copy requested; check your clipboard".to_string(), + Err(error) => format!("Could not copy link: {error}"), + }; + if let Some(Step::Auth(widget)) = onboarding_screen.current_steps_mut().into_iter().last() { + *widget.error.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(message); + } + tui.frame_requester().schedule_frame(); + } else { + onboarding_screen.handle_key_event(key_event); + } if !directory_trust_persisted { directory_trust_persisted = persist_selected_trust( &mut onboarding_screen, @@ -586,6 +643,25 @@ async fn run_onboarding_screen( | TuiEvent::Resize(_) | TuiEvent::FocusGained | TuiEvent::FocusLost => { + if let Some((id, result)) = tui.clipboard.poll().cloned() + && let Some((pending_id, login_id)) = &pending_copy + && id == *pending_id + { + if let Some(Step::Auth(widget)) = onboarding_screen.current_steps_mut().into_iter().last() + && widget.sign_in_state.read().is_ok_and(|state| { + matches!(&*state, SignInState::ChatGptContinueInBrowser(state) if state.login_id == *login_id) + }) + { + let message = match result { + Ok(CopyStatus::Confirmed) => "Copied link to clipboard".to_string(), + Ok(CopyStatus::Unconfirmed) => "Copy requested; check your clipboard".to_string(), + Ok(CopyStatus::Busy | CopyStatus::Pending(_)) => "Clipboard busy; try again".to_string(), + Err(error) => format!("Could not copy link: {error}"), + }; + *widget.error.write().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(message); + } + pending_copy = None; + } for step in &onboarding_screen.steps { if let Step::Welcome(widget) = step { if matches!(&event, TuiEvent::Resume) { diff --git a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__continue_in_browser_narrow_long_url.snap b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__continue_in_browser_narrow_long_url.snap index d4262b5386..f8f65e2779 100644 --- a/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__continue_in_browser_narrow_long_url.snap +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__continue_in_browser_narrow_long_url.snap @@ -5,7 +5,7 @@ expression: contents Finish signing in via your browser If the link doesn't open automatically, -open the following link to authenticate: +press c to copy it: https://auth.openai.com/oauth/authorize?re sponse_type=code&client_id=app_EMoamEEZ73f0C diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index dbe264f4b8..27b1950d0a 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -629,11 +629,12 @@ pub enum TuiEvent { FocusLost, } -/// The overlay requesting pointer reports; ordinary pickers retain alternate-scroll input. +/// The current screen's pointer policy; ordinary pickers retain alternate-scroll input. #[derive(Clone, Copy, Default, PartialEq, Eq)] pub(crate) enum OverlayInput { #[default] Default, + Onboarding, Transcript, StaticPager, Usage, @@ -643,7 +644,7 @@ impl OverlayInput { fn captures_mouse(self, owned_screen: bool) -> bool { match self { Self::Default => owned_screen, - Self::StaticPager => false, + Self::Onboarding | Self::StaticPager => false, Self::Transcript | Self::Usage => true, } } @@ -676,7 +677,7 @@ pub struct Tui { alt_screen_enabled: bool, // Keep the alternate screen alive when an overlay closes. owned_screen: bool, - overlay_input: OverlayInput, + pub(crate) overlay_input: OverlayInput, // Copies and native ownership survive closing an overlay or startup picker. pub(crate) clipboard: crate::clipboard_copy::worker::ClipboardWorker, // Keeps unmanaged process stderr writes out of the inline viewport. diff --git a/codex-rs/tui/src/tui/alternate_screen_tests.rs b/codex-rs/tui/src/tui/alternate_screen_tests.rs index 799c8eb83e..eada79cadd 100644 --- a/codex-rs/tui/src/tui/alternate_screen_tests.rs +++ b/codex-rs/tui/src/tui/alternate_screen_tests.rs @@ -35,12 +35,18 @@ fn refreshed_mouse_policy_applies_to_fullscreen_and_overlays() { for next in [ OverlayInput::Transcript, OverlayInput::Usage, - OverlayInput::StaticPager, + OverlayInput::Onboarding, OverlayInput::Default, + OverlayInput::StaticPager, ] { input.apply(&screen, &mut output, next, owned).unwrap(); terminal.process(&std::mem::take(&mut output)); - let expected = if !disabled && next.captures_mouse(owned) { + let capture = match next { + OverlayInput::Default => owned, + OverlayInput::Transcript | OverlayInput::Usage => true, + OverlayInput::StaticPager | OverlayInput::Onboarding => false, + }; + let expected = if !disabled && capture { vt100::MouseProtocolMode::AnyMotion } else { vt100::MouseProtocolMode::None