mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
Coalesce wrapped OSC 8 hyperlinks in the TUI terminal (#34778)
## What changed - Track adjacent cells with the same OSC 8 destination and emit a single hyperlink around their visible text. - Close active hyperlinks before non-link content and at the end of a draw. - Cover a production-length browser authentication URL at narrow terminal width, including the remote-login guidance and cancellation footer. GitOrigin-RevId: 9b87166ce78578137dfd2bf19db359ac83a908ee
This commit is contained in:
@@ -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<Position> = None;
|
||||
let mut active_hyperlink: Option<String> = 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),
|
||||
};
|
||||
// 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))?;
|
||||
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"))?;
|
||||
}
|
||||
last_pos = Some(Position { x, y });
|
||||
match command {
|
||||
// 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))?;
|
||||
}
|
||||
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<u8>,
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user