Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 79 additions & 8 deletions codex-rs/tui/src/custom_terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
///
Expand Down Expand Up @@ -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),
};
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 {
Expand All @@ -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!(
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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();
Expand Down
49 changes: 41 additions & 8 deletions codex-rs/tui/src/onboarding/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading