Skip to content
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
20 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
6976202
Merge branch 'main' into fix/bug-report-url-wrap
Daniel-Santiago-Acosta-1013 Jan 14, 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>> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs a doc comment clarifying intent

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit (subjective preference): I'd prefer to see this below where it's called as it makes the code easier generally to read top-to-bottom outside-in.

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())
};
Comment on lines +33 to +37
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems a bit odd. I'm not sure I understand why we need to do this rather than relying on initial indent directly here.

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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love the width() call here (it's likely not a significant perf hit, but becomes one easily with large enough history which can cause rendering slowdowns - more-so in tui2 than tui.

Not a big deal, but if there's a simple fix to avoid this (like storing the line + width computed once here) that would be great.

rows = rows.saturating_add(line_width.div_ceil(width));
}
Comment on lines +50 to +53
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can probably be more written more succinctly / obviously with map + sum

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add covering tests (make codex produce these so that all the edge cases / behavior are properly tested).

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