diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 25566ab38de5..57f5049a5110 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -79,6 +79,17 @@ fn display_width(s: &str) -> usize { visible.width() } +fn osc8_hyperlink_parts(symbol: &str) -> Option<(&str, &str)> { + let content = symbol.strip_prefix("\x1b]8;;")?; + let destination_end = content.find('\x07')?; + let destination = &content[..destination_end]; + if destination.is_empty() { + return None; + } + let visible = content[destination_end + 1..].strip_suffix("\x1b]8;;\x07")?; + Some((destination, visible)) +} + pub struct Frame<'a> { /// Where should the cursor be after drawing this frame? /// @@ -646,17 +657,27 @@ where let mut bg = Color::Reset; let mut modifier = Modifier::empty(); let mut last_pos: Option = None; + let mut active_hyperlink: Option = None; for command in commands { - let (x, y) = match command { + let (x, y) = match &command { DrawCommand::Put { x, y, .. } => (x, y), DrawCommand::ClearToEnd { x, y, .. } => (x, y), }; + let hyperlink = match &command { + DrawCommand::Put { cell, .. } => osc8_hyperlink_parts(cell.symbol()), + DrawCommand::ClearToEnd { .. } => None, + }; + let destination = hyperlink.map(|(destination, _)| destination); + let hyperlink_changed = active_hyperlink.as_deref() != destination; + if hyperlink_changed && active_hyperlink.is_some() { + queue!(writer, Print("\x1b]8;;\x07"))?; + } // Move the cursor if the previous location was not (x - 1, y) - if !matches!(last_pos, Some(p) if x == p.x + 1 && y == p.y) { - queue!(writer, MoveTo(x, y))?; + if !matches!(last_pos, Some(p) if *x == p.x + 1 && *y == p.y) { + queue!(writer, MoveTo(*x, *y))?; } - last_pos = Some(Position { x, y }); - match command { + last_pos = Some(Position { x: *x, y: *y }); + match &command { DrawCommand::Put { cell, .. } => { if cell.modifier != modifier { let diff = ModifierDiff { @@ -675,16 +696,26 @@ where bg = cell.bg; } - queue!(writer, Print(cell.symbol()))?; + if hyperlink_changed && let Some(destination) = destination { + queue!(writer, Print(format!("\x1b]8;;{destination}\x07")))?; + } + let symbol = hyperlink.map_or_else(|| cell.symbol(), |(_, visible)| visible); + queue!(writer, Print(symbol))?; } DrawCommand::ClearToEnd { bg: clear_bg, .. } => { queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))?; modifier = Modifier::empty(); - queue!(writer, SetBackgroundColor(clear_bg.into()))?; - bg = clear_bg; + queue!(writer, SetBackgroundColor((*clear_bg).into()))?; + bg = *clear_bg; queue!(writer, Clear(crossterm::terminal::ClearType::UntilNewLine))?; } } + if hyperlink_changed { + active_hyperlink = destination.map(str::to_owned); + } + } + if active_hyperlink.is_some() { + queue!(writer, Print("\x1b]8;;\x07"))?; } queue!( @@ -771,6 +802,11 @@ mod tests { use ratatui::backend::WindowSize; use ratatui::layout::Rect; use ratatui::style::Style; + use ratatui::style::Stylize; + use ratatui::text::Line; + use ratatui::widgets::Paragraph; + use ratatui::widgets::Widget; + use ratatui::widgets::Wrap; struct CaptureBackend { output: Vec, @@ -918,6 +954,41 @@ mod tests { ); } + #[test] + fn terminal_draw_coalesces_wrapped_hyperlink_output() { + let auth_url = format!( + "https://auth.openai.com/oauth/authorize?response_type=code&state={}", + "x".repeat(/*n*/ 400) + ); + let width = 44; + let height = 20; + let area = Rect::new(0, 0, width, height); + let mut terminal = + Terminal::with_options(CaptureBackend::new(width, height)).expect("terminal"); + terminal.set_viewport_area(area); + + terminal + .draw(|frame| { + Paragraph::new(vec![ + Line::from(vec![" ".into(), auth_url.as_str().cyan().underlined()]), + "".into(), + " Press Esc to cancel".into(), + ]) + .wrap(Wrap { trim: false }) + .render(area, frame.buffer_mut()); + crate::terminal_hyperlinks::mark_url_hyperlink(frame.buffer_mut(), area, &auth_url); + }) + .expect("draw"); + + let output = terminal.backend().output(); + let open = format!("\x1b]8;;{auth_url}\x07"); + let close = "\x1b]8;;\x07"; + assert_eq!(output.matches(&open).count(), 1); + assert_eq!(output.matches(close).count(), 1); + let footer = output.find("Press").expect("footer"); + assert!(output.find(close).expect("hyperlink close") < footer); + } + #[test] fn terminal_draw_applies_requested_cursor_style() { let mut output = Vec::new(); diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 609b52b26086..08c35b96f25e 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1032,6 +1032,21 @@ mod tests { use std::sync::Arc; use tempfile::TempDir; + const PRODUCTION_LENGTH_AUTH_URL: &str = concat!( + "https://auth.openai.com/oauth/authorize?", + "response_type=code&", + "client_id=app_EMoamEEZ73f0CkXaXp7hrann&", + "redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&", + "scope=openid%20profile%20email%20offline_access%20", + "api.connectors.read%20api.connectors.invoke&", + "code_challenge=1YM3Z8QbrLbdt9C3eX3j7UQ4GmFRmKz4OeVYwD6s5xA&", + "code_challenge_method=S256&", + "id_token_add_organizations=true&", + "codex_cli_simplified_flow=true&", + "state=8cHjQ4nVx2Yp7Lm9Rk3Wf6Ta1Bs5Du0Ei4Go7Nz2PqM&", + "originator=codex_cli_rs" + ); + async fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { let codex_home = TempDir::new().unwrap(); let codex_home_path = codex_home.path().to_path_buf(); @@ -1198,24 +1213,42 @@ mod tests { } #[test] - fn continue_in_browser_renders_osc8_hyperlink() { + fn continue_in_browser_preserves_long_link_and_footer_at_narrow_width() { let runtime = tokio::runtime::Runtime::new().unwrap(); let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt()); - let url = "https://auth.example.com/login?state=abc123"; + widget.set_animations_suppressed(/*suppressed*/ true); *widget.sign_in_state.write().unwrap() = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { login_id: "login-1".to_string(), - auth_url: url.to_string(), + auth_url: PRODUCTION_LENGTH_AUTH_URL.to_string(), }); - // Render into a narrow buffer so the URL wraps across multiple rows. - let area = Rect::new(0, 0, 30, 20); + let width = 44; + let height = 30; + let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); widget.render_continue_in_browser(area, &mut buf); - // Every character of the URL should be present as an OSC 8 cell. - let found = collect_osc8_chars(&buf, area, url); - assert_eq!(found, url, "OSC 8 hyperlink should cover the full URL"); + let found = collect_osc8_chars(&buf, area, PRODUCTION_LENGTH_AUTH_URL); + assert_eq!( + found, PRODUCTION_LENGTH_AUTH_URL, + "OSC 8 hyperlink should cover the full URL" + ); + + let mut terminal = crate::custom_terminal::Terminal::with_options( + crate::test_backend::VT100Backend::new(width, height), + ) + .expect("terminal"); + terminal.set_viewport_area(area); + + terminal + .draw(|frame| widget.render_continue_in_browser(area, frame.buffer_mut())) + .expect("draw"); + + let contents = terminal.backend().to_string(); + insta::assert_snapshot!("continue_in_browser_narrow_long_url", contents); + assert!(contents.contains("On a remote or headless machine?")); + assert!(contents.contains("Press esc to cancel")); } #[test] 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 new file mode 100644 index 000000000000..d4262b53863d --- /dev/null +++ b/codex-rs/tui/src/onboarding/snapshots/codex_tui__onboarding__auth__tests__continue_in_browser_narrow_long_url.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/onboarding/auth.rs +expression: contents +--- + Finish signing in via your browser + + If the link doesn't open automatically, +open the following link to authenticate: + + https://auth.openai.com/oauth/authorize?re +sponse_type=code&client_id=app_EMoamEEZ73f0C +kXaXp7hrann&redirect_uri=http%3A%2F%2Flocalh +ost%3A1455%2Fauth%2Fcallback&scope=openid%20 +profile%20email%20offline_access%20api.conne +ctors.read%20api.connectors.invoke&code_chal +lenge=1YM3Z8QbrLbdt9C3eX3j7UQ4GmFRmKz4OeVYwD +6s5xA&code_challenge_method=S256&id_token_ad +d_organizations=true&codex_cli_simplified_fl +ow=true&state=8cHjQ4nVx2Yp7Lm9Rk3Wf6Ta1Bs5Du +0Ei4Go7Nz2PqM&originator=codex_cli_rs + + On a remote or headless machine? Press esc +and choose Sign in with Device Code. + + Press esc to cancel