Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
756324a
fix(tui): avoid hard-wrapping URL lines in history
Daniel-Santiago-Acosta-1013 Jan 5, 2026
cd88cbe
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 5, 2026
27f8434
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 6, 2026
368cd4a
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 6, 2026
1f460aa
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 6, 2026
2a94512
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 6, 2026
2718474
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 6, 2026
e29c887
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 7, 2026
3924eb9
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 7, 2026
d02735e
Merge branch 'main' into fix/bug-report-url-wrap
etraut-openai Jan 7, 2026
76598df
Merge branch 'main' into fix/bug-report-url-wrap
joshka-oai Jan 8, 2026
844261f
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 9, 2026
d38a801
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 9, 2026
b1a639b
fix: bug report url wrap
Daniel-Santiago-Acosta-1013 Jan 9, 2026
e7ac563
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 9, 2026
b69e937
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 9, 2026
e65bde3
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 9, 2026
ae5d64e
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 10, 2026
1b28ad7
Merge branch 'main' into fix/bug-report-url-wrap
etraut-openai Jan 12, 2026
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
88 changes: 85 additions & 3 deletions codex-rs/tui/src/insert_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ use std::fmt;
use std::io;
use std::io::Write;

use crate::wrapping::word_wrap_lines_borrowed;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line_url_aware;
use crossterm::Command;
use crossterm::cursor::MoveTo;
use crossterm::queue;
Expand All @@ -22,6 +23,37 @@ use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;

fn wrap_history_lines<'a>(lines: &'a [Line<'a>], width: u16) -> Vec<Line<'a>> {
let base_opts = RtOptions::new(width.max(1) as usize);
let mut wrapped: Vec<Line<'a>> = Vec::new();
let mut first = true;
for line in lines {
let opts = if first {
base_opts.clone()
} else {
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
wrapped.extend(word_wrap_line_url_aware(line, opts));
first = false;
}
wrapped
}

/// Counts the number of terminal rows needed after wrapping `lines` at `width`.
/// This accounts for any long tokens (like URLs) that are still forced to
/// hard-wrap by the terminal even after word-aware wrapping.
fn wrapped_row_count(lines: &[Line<'_>], width: u16) -> u16 {
let width = width.max(1) as usize;
let mut rows = 0usize;
for line in lines {
let line_width = line.width().max(1);
rows = rows.saturating_add(line_width.div_ceil(width));
}
u16::try_from(rows).unwrap_or(u16::MAX)
}

/// Insert `lines` above the viewport using the terminal's backend writer
/// (avoids direct stdout references).
pub fn insert_history_lines<B>(
Expand All @@ -40,8 +72,8 @@ where

// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
let wrapped = word_wrap_lines_borrowed(&lines, area.width.max(1) as usize);
let wrapped_lines = wrapped.len() as u16;
let wrapped = wrap_history_lines(&lines, area.width);
let wrapped_lines = wrapped_row_count(&wrapped, area.width);
let cursor_top = if area.bottom() < screen_size.height {
// If the viewport is not at the bottom of the screen, scroll it down to make room.
// Don't scroll it past the bottom of the screen.
Expand Down Expand Up @@ -287,6 +319,7 @@ mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
use crate::test_backend::VT100Backend;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::style::Color;

Expand Down Expand Up @@ -318,6 +351,55 @@ mod tests {
);
}

#[test]
fn wrap_history_lines_wraps_before_url() {
let url = "http://foobar";
let line: Line<'static> =
Line::from("Here is some text and a http://foobar url in the middle");

let lines = [line];
let wrapped = wrap_history_lines(&lines, 24);
let rendered: Vec<String> = wrapped
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect();

let Some(url_line) = rendered.iter().find(|line| line.contains(url)) else {
panic!("expected wrapped output to contain url");
};

assert!(url_line.starts_with(url));
}

#[test]
fn wrap_history_lines_breaks_long_url_when_too_long() {
let url = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml&steps=Uploaded%20thread:thread-1";
let line: Line<'static> = Line::from(url);
let width = 20u16;

let lines = [line];
let wrapped = wrap_history_lines(&lines, width);
let rows = wrapped_row_count(&wrapped, width);
let rendered: Vec<String> = wrapped
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect();

assert!(wrapped.len() > 1);
assert!(rendered.iter().all(|line| !line.contains(url)));
assert_eq!(rows as usize, wrapped.len());
}

#[test]
fn vt100_blockquote_line_emits_green_fg() {
// Set up a small off-screen terminal
Expand Down
74 changes: 74 additions & 0 deletions codex-rs/tui/src/wrapping.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,80 @@ where
out
}

#[must_use]
pub(crate) fn word_wrap_line_url_aware<'a, O>(
line: &'a Line<'a>,
width_or_options: O,
) -> Vec<Line<'a>>
where
O: Into<RtOptions<'a>>,
{
let mut rt_opts: RtOptions<'a> = width_or_options.into();
rt_opts.word_separator = textwrap::WordSeparator::Custom(url_aware_words);
word_wrap_line(line, rt_opts)
}

fn url_aware_words<'a>(line: &'a str) -> Box<dyn Iterator<Item = textwrap::core::Word<'a>> + 'a> {
if !line.contains("http://") && !line.contains("https://") {
return textwrap::WordSeparator::new().find_words(line);
}

let mut words: Vec<textwrap::core::Word<'a>> = Vec::new();
let mut cursor = 0usize;
let separator = textwrap::WordSeparator::new();

while let Some((url_start, url_end)) = find_next_url(line, cursor) {
if cursor < url_start {
words.extend(separator.find_words(&line[cursor..url_start]));
}
let url_with_spaces_end = consume_trailing_spaces(line, url_end);
words.push(textwrap::core::Word::from(
&line[url_start..url_with_spaces_end],
));
cursor = url_with_spaces_end;
}

if cursor < line.len() {
words.extend(separator.find_words(&line[cursor..]));
}

Box::new(words.into_iter())
}

fn find_next_url(line: &str, start: usize) -> Option<(usize, usize)> {
let http = line[start..].find("http://");
let https = line[start..].find("https://");
let (rel_start, scheme_len) = match (http, https) {
(Some(http_pos), Some(https_pos)) => {
if http_pos <= https_pos {
(http_pos, "http://".len())
} else {
(https_pos, "https://".len())
}
}
(Some(http_pos), None) => (http_pos, "http://".len()),
(None, Some(https_pos)) => (https_pos, "https://".len()),
(None, None) => return None,
};
let url_start = start + rel_start;
let mut url_end = line.len();
for (offset, ch) in line[url_start + scheme_len..].char_indices() {
if ch.is_whitespace() {
url_end = url_start + scheme_len + offset;
break;
}
}
Some((url_start, url_end))
}

fn consume_trailing_spaces(line: &str, mut idx: usize) -> usize {
let bytes = line.as_bytes();
while matches!(bytes.get(idx), Some(b' ')) {
idx += 1;
}
idx
}

/// Utilities to allow wrapping either borrowed or owned lines.
#[derive(Debug)]
enum LineInput<'a> {
Expand Down
82 changes: 79 additions & 3 deletions codex-rs/tui2/src/insert_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ use std::fmt;
use std::io;
use std::io::Write;

use crate::wrapping::word_wrap_lines_borrowed;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crossterm::Command;
use crossterm::cursor::MoveTo;
use crossterm::queue;
Expand All @@ -42,6 +43,46 @@ use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;

fn line_contains_url(line: &Line<'_>) -> bool {
let mut text = String::new();
for span in &line.spans {
text.push_str(span.content.as_ref());
}
text.contains("https://") || text.contains("http://")
}

fn wrap_history_lines<'a>(lines: &'a [Line<'a>], width: u16) -> Vec<Line<'a>> {
let base_opts = RtOptions::new(width.max(1) as usize);
let mut wrapped: Vec<Line<'a>> = Vec::new();
let mut first = true;
for line in lines {
if line_contains_url(line) {
wrapped.push(line.clone());
} else {
let opts = if first {
base_opts.clone()
} else {
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
wrapped.extend(word_wrap_line(line, opts));
}
first = false;
}
wrapped
}

fn wrapped_row_count(lines: &[Line<'_>], width: u16) -> u16 {
let width = width.max(1) as usize;
let mut rows = 0usize;
for line in lines {
let line_width = line.width().max(1);
rows = rows.saturating_add(line_width.div_ceil(width));
}
u16::try_from(rows).unwrap_or(u16::MAX)
}

/// Insert `lines` above the viewport using the terminal's backend writer
/// (avoids direct stdout references).
pub fn insert_history_lines<B>(
Expand All @@ -60,8 +101,8 @@ where

// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
let wrapped = word_wrap_lines_borrowed(&lines, area.width.max(1) as usize);
let wrapped_lines = wrapped.len() as u16;
let wrapped = wrap_history_lines(&lines, area.width);
let wrapped_lines = wrapped_row_count(&wrapped, area.width);
let cursor_top = if area.bottom() < screen_size.height {
// If the viewport is not at the bottom of the screen, scroll it down to make room.
// Don't scroll it past the bottom of the screen.
Expand Down Expand Up @@ -324,6 +365,41 @@ mod tests {
);
}

#[test]
fn wrap_history_lines_preserves_long_url() {
let url = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml&steps=Uploaded%20thread:thread-1";
let line: Line<'static> = Line::from(vec![" ".into(), url.into()]);

let lines = [line];
let wrapped = wrap_history_lines(&lines, 20);
let rendered: Vec<String> = wrapped
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect();

assert_eq!(rendered, vec![format!(" {url}")]);
}

#[test]
fn wrapped_row_count_accounts_for_long_url() {
let url = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml&steps=Uploaded%20thread:thread-1";
let line: Line<'static> = Line::from(vec![" ".into(), url.into()]);
let width = 20u16;

let lines = [line];
let wrapped = wrap_history_lines(&lines, width);
let rows = wrapped_row_count(&wrapped, width);
let line_width = 2 + url.len();
let expected_rows = line_width.div_ceil(width as usize);

assert_eq!(rows, expected_rows as u16);
}

#[test]
fn write_spans_emits_truecolor_and_indexed_sgr() {
// This test asserts that `write_spans` emits the correct SGR sequences for colors that
Expand Down
Loading