Make onboarding login links easier to copy (#48544)

## Why

When browser sign-in does not open automatically, users need to copy the login URL. Onboarding should also allow terminal selection of login URLs and device codes in fullscreen mode.

## What changed

- Add a `c` shortcut to copy the browser sign-in URL, with status messages for pending, successful, unconfirmed, busy, and failed clipboard requests.
- Apply asynchronous clipboard results only to the matching login attempt.
- Disable mouse capture during onboarding so the terminal can select text, and restore the previous input policy afterward.

## Testing

Update the narrow-screen login snapshot to show the copy shortcut and extend mouse-policy coverage to verify onboarding leaves mouse capture disabled.

GitOrigin-RevId: 01676b737efb192d900da306cd2c6b32f791ceb9
(cherry picked from commit 7ed14a27db)
This commit is contained in:
Eric Traut
2026-09-26 15:49:13 -07:00
committed by Ian MacLeod
parent a7711b6ed9
commit 001bad70dd
6 changed files with 101 additions and 10 deletions
+10 -3
View File
@@ -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(),
+1
View File
@@ -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')),
@@ -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<AppServerRequestHandle>,
app_server: Option<&mut AppServerSession>,
tui: &mut Tui,
) -> Result<OnboardingResult> {
// 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<AppServerRequestHandle>,
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::<str>::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) {
@@ -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
+4 -3
View File
@@ -604,11 +604,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,
@@ -618,7 +619,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,
}
}
@@ -651,7 +652,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.
@@ -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