diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 698825d6f..2e6fab3d2 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -10,6 +10,7 @@ use ratatui::text::Line; use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use ratatui::widgets::Wrap; use textwrap::wrap; use super::CancellationEvent; @@ -24,7 +25,8 @@ use crate::key_hint; use crate::render::Insets; use crate::render::RectExt as _; use crate::style::user_message_style; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_lines; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AppLinkScreen { @@ -234,7 +236,10 @@ impl AppLinkView { lines.push(Line::from("")); lines.push(Line::from(vec!["Setup URL:".dim()])); let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); - lines.extend(word_wrap_lines(vec![url_line], usable_width)); + lines.extend(adaptive_wrap_lines( + vec![url_line], + RtOptions::new(usable_width), + )); lines } @@ -374,8 +379,12 @@ impl crate::render::renderable::Renderable for AppLinkView { fn desired_height(&self, width: u16) -> u16 { let content_width = width.saturating_sub(4).max(1); let content_lines = self.content_lines(content_width); + let content_rows = Paragraph::new(content_lines) + .wrap(Wrap { trim: false }) + .line_count(content_width) + .max(1) as u16; let action_rows_height = self.action_rows_height(content_width); - content_lines.len() as u16 + action_rows_height + 3 + content_rows + action_rows_height + 3 } fn render(&self, area: Rect, buf: &mut Buffer) { @@ -398,7 +407,9 @@ impl crate::render::renderable::Renderable for AppLinkView { let inner = content_area.inset(Insets::vh(1, 2)); let content_width = inner.width.max(1); let lines = self.content_lines(content_width); - Paragraph::new(lines).render(inner, buf); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(inner, buf); if actions_area.height > 0 { let actions_area = Rect { @@ -435,6 +446,7 @@ impl crate::render::renderable::Renderable for AppLinkView { mod tests { use super::*; use crate::app_event::AppEvent; + use crate::render::renderable::Renderable; use tokio::sync::mpsc::unbounded_channel; #[test] @@ -493,4 +505,92 @@ mod tests { vec!["Manage on ChatGPT", "Enable app", "Back"] ); } + + #[test] + fn install_confirmation_does_not_split_long_url_like_token_without_scheme() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: url_like.to_string(), + is_installed: true, + is_enabled: true, + }, + tx, + ); + view.screen = AppLinkScreen::InstallConfirmation; + + let rendered: Vec = view + .content_lines(40) + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.into_owned()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn install_confirmation_render_keeps_url_tail_visible_when_narrow() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42"; + let mut view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_1".to_string(), + title: "Notion".to_string(), + description: None, + instructions: "Manage app".to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + }, + tx, + ); + view.screen = AppLinkScreen::InstallConfirmation; + + let width: u16 = 36; + let height = view.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let rendered_blob = (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + rendered_blob.contains("tail42"), + "expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}" + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs index 010b88111..30a525f45 100644 --- a/codex-rs/tui/src/bottom_pane/queued_user_messages.rs +++ b/codex-rs/tui/src/bottom_pane/queued_user_messages.rs @@ -8,7 +8,7 @@ use ratatui::widgets::Paragraph; use crate::key_hint; use crate::render::renderable::Renderable; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::adaptive_wrap_lines; /// Widget that displays a list of user messages queued while a turn is in progress. /// @@ -46,7 +46,7 @@ impl QueuedUserMessages { let mut lines = vec![]; for message in &self.messages { - let wrapped = word_wrap_lines( + let wrapped = adaptive_wrap_lines( message.lines().map(|line| line.dim().italic()), RtOptions::new(width as usize) .initial_indent(Line::from(" ↳ ".dim())) @@ -170,4 +170,36 @@ mod tests { queue.render(Rect::new(0, 0, width, height), &mut buf); assert_snapshot!("render_many_line_message", format!("{buf:?}")); } + + #[test] + fn long_url_like_message_does_not_expand_into_wrapped_ellipsis_rows() { + let mut queue = QueuedUserMessages::new(); + queue.messages.push( + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789" + .to_string(), + ); + + let width = 36; + let height = queue.desired_height(width); + assert_eq!( + height, 2, + "expected one message row plus hint row for URL-like token" + ); + + let mut buf = Buffer::empty(Rect::new(0, 0, width, height)); + queue.render(Rect::new(0, 0, width, height), &mut buf); + + let rendered_rows = (0..height) + .map(|y| { + (0..width) + .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect::() + }) + .collect::>(); + + assert!( + !rendered_rows.iter().any(|row| row.contains('…')), + "expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}" + ); + } } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 26284a7fa..a749ca5d2 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -43,6 +43,40 @@ use ratatui::layout::Size; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::widgets::WidgetRef; +use unicode_width::UnicodeWidthStr; + +/// Returns the display width of a cell symbol, ignoring OSC escape sequences. +/// +/// OSC sequences (e.g. OSC 8 hyperlinks: `\x1B]8;;URL\x07`) are terminal +/// control sequences that don't consume display columns. The standard +/// `UnicodeWidthStr::width()` method incorrectly counts the printable +/// characters inside OSC payloads (like `]`, `8`, `;`, and URL characters). +/// This function strips them first so that only visible characters contribute +/// to the width. +fn display_width(s: &str) -> usize { + // Fast path: no escape sequences present. + if !s.contains('\x1B') { + return s.width(); + } + + // Strip OSC sequences: ESC ] ... BEL + let mut visible = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(ch) = chars.next() { + if ch == '\x1B' && chars.clone().next() == Some(']') { + // Consume the ']' and everything up to and including BEL. + chars.next(); // skip ']' + for c in chars.by_ref() { + if c == '\x07' { + break; + } + } + continue; + } + visible.push(ch); + } + visible.width() +} #[derive(Debug, Hash)] pub struct Frame<'a> { @@ -412,7 +446,6 @@ where } use ratatui::buffer::Cell; -use unicode_width::UnicodeWidthStr; #[derive(Debug, IsVariant)] enum DrawCommand { @@ -441,7 +474,7 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { let mut column = 0usize; while column < row.len() { let cell = &row[column]; - let width = cell.symbol().width(); + let width = display_width(cell.symbol()); if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { last_nonblank_column = column + (width.saturating_sub(1)); } @@ -474,9 +507,12 @@ fn diff_buffers(a: &Buffer, b: &Buffer) -> Vec { } } - to_skip = current.symbol().width().saturating_sub(1); + to_skip = display_width(current.symbol()).saturating_sub(1); - let affected_width = std::cmp::max(current.symbol().width(), previous.symbol().width()); + let affected_width = std::cmp::max( + display_width(current.symbol()), + display_width(previous.symbol()), + ); invalidated = std::cmp::max(affected_width, invalidated).saturating_sub(1); } updates diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 6e57c2152..14f48529d 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -10,8 +10,8 @@ use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; use crate::shimmer::shimmer_spans; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::adaptive_wrap_line; +use crate::wrapping::adaptive_wrap_lines; use codex_ansi_escape::ansi_escape_line; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ExecCommandSource; @@ -21,6 +21,8 @@ use itertools::Itertools; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::style::Stylize; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; use textwrap::WordSplitter; use unicode_width::UnicodeWidthStr; @@ -202,10 +204,6 @@ impl HistoryCell for ExecCell { } } - fn desired_transcript_height(&self, width: u16) -> u16 { - self.transcript_lines(width).len() as u16 - } - fn transcript_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = vec![]; for (i, call) in self.iter_calls().enumerate() { @@ -214,7 +212,7 @@ impl HistoryCell for ExecCell { } let script = strip_bash_lc_and_escape(&call.command); let highlighted_script = highlight_bash_to_lines(&script); - let cmd_display = word_wrap_lines( + let cmd_display = adaptive_wrap_lines( &highlighted_script, RtOptions::new(width as usize) .initial_indent("$ ".magenta().into()) @@ -227,7 +225,7 @@ impl HistoryCell for ExecCell { let wrap_width = width.max(1) as usize; let wrap_opts = RtOptions::new(wrap_width); for unwrapped in output.formatted_output.lines().map(ansi_escape_line) { - let wrapped = word_wrap_line(&unwrapped, wrap_opts.clone()); + let wrapped = adaptive_wrap_line(&unwrapped, wrap_opts.clone()); push_owned_lines(&wrapped, &mut lines); } } @@ -341,7 +339,7 @@ impl ExecCell { let line = Line::from(line); let initial_indent = Line::from(vec![title.cyan(), " ".into()]); let subsequent_indent = " ".repeat(initial_indent.width()).into(); - let wrapped = word_wrap_line( + let wrapped = adaptive_wrap_line( &line, RtOptions::new(width as usize) .initial_indent(initial_indent) @@ -401,8 +399,9 @@ impl ExecCell { let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1); let first_opts = RtOptions::new(available_first_width).word_splitter(WordSplitter::NoHyphenation); + let mut first_wrapped: Vec> = Vec::new(); - push_owned_lines(&word_wrap_line(first, first_opts), &mut first_wrapped); + push_owned_lines(&adaptive_wrap_line(first, first_opts), &mut first_wrapped); let mut first_wrapped_iter = first_wrapped.into_iter(); if let Some(first_segment) = first_wrapped_iter.next() { header_line.extend(first_segment); @@ -411,7 +410,7 @@ impl ExecCell { for line in rest { push_owned_lines( - &word_wrap_line(line, continuation_opts.clone()), + &adaptive_wrap_line(line, continuation_opts.clone()), &mut continuation_lines, ); } @@ -470,20 +469,28 @@ impl ExecCell { RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); for line in &raw_output.lines { push_owned_lines( - &word_wrap_line(line, output_opts.clone()), + &adaptive_wrap_line(line, output_opts.clone()), &mut wrapped_output, ); } - let trimmed_output = - Self::truncate_lines_middle(&wrapped_output, display_limit, raw_output.omitted); + let prefixed_output = prefix_lines( + wrapped_output, + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + ); + let trimmed_output = Self::truncate_lines_middle( + &prefixed_output, + display_limit, + width, + raw_output.omitted, + Some(Line::from( + Span::from(layout.output_block.subsequent_prefix).dim(), + )), + ); if !trimmed_output.is_empty() { - lines.extend(prefix_lines( - trimmed_output, - Span::from(layout.output_block.initial_prefix).dim(), - Span::from(layout.output_block.subsequent_prefix), - )); + lines.extend(trimmed_output); } } } @@ -504,18 +511,55 @@ impl ExecCell { out } + /// Truncates a list of lines to fit within `max_rows` viewport rows, + /// keeping a head portion and a tail portion with an ellipsis line + /// in between. + /// + /// `max_rows` is measured in viewport rows (the actual space a line + /// occupies after `Paragraph::wrap`), not logical lines. Each line's + /// row cost is computed via `Paragraph::line_count` at the given + /// `width`. This ensures that a single logical line containing a + /// long URL (which wraps to several viewport rows) is properly + /// accounted for. + /// + /// The ellipsis message reports the number of omitted *lines* + /// (logical, not rows) to keep the count stable across terminal + /// widths. `omitted_hint` carries forward any previously reported + /// omitted count (from upstream truncation); `ellipsis_prefix` + /// prepends the output gutter prefix to the ellipsis line. fn truncate_lines_middle( lines: &[Line<'static>], - max: usize, + max_rows: usize, + width: u16, omitted_hint: Option, + ellipsis_prefix: Option>, ) -> Vec> { - if max == 0 { + let width = width.max(1); + if max_rows == 0 { return Vec::new(); } - if lines.len() <= max { + let line_rows: Vec = lines + .iter() + .map(|line| { + let is_whitespace_only = line + .spans + .iter() + .all(|span| span.content.chars().all(char::is_whitespace)); + if is_whitespace_only { + line.width().div_ceil(usize::from(width)).max(1) + } else { + Paragraph::new(Text::from(vec![line.clone()])) + .wrap(Wrap { trim: false }) + .line_count(width) + .max(1) + } + }) + .collect(); + let total_rows: usize = line_rows.iter().sum(); + if total_rows <= max_rows { return lines.to_vec(); } - if max == 1 { + if max_rows == 1 { // Carry forward any previously omitted count and add any // additionally hidden content lines from this truncation. let base = omitted_hint.unwrap_or(0); @@ -526,27 +570,53 @@ impl ExecCell { .len() .saturating_sub(usize::from(omitted_hint.is_some())); let omitted = base + extra; - return vec![Self::ellipsis_line(omitted)]; + return vec![Self::ellipsis_line_with_prefix( + omitted, + ellipsis_prefix.as_ref(), + )]; } - let head = (max - 1) / 2; - let tail = max - head - 1; - let mut out: Vec> = Vec::new(); - - if head > 0 { - out.extend(lines[..head].iter().cloned()); + let head_budget = (max_rows - 1) / 2; + let tail_budget = max_rows - head_budget - 1; + let mut head_lines: Vec> = Vec::new(); + let mut head_rows = 0usize; + let mut head_end = 0usize; + while head_end < lines.len() { + let line_row_count = line_rows[head_end]; + if head_rows + line_row_count > head_budget { + break; + } + head_rows += line_row_count; + head_lines.push(lines[head_end].clone()); + head_end += 1; } + let mut tail_lines_reversed: Vec> = Vec::new(); + let mut tail_rows = 0usize; + let mut tail_start = lines.len(); + while tail_start > head_end { + let idx = tail_start - 1; + let line_row_count = line_rows[idx]; + if tail_rows + line_row_count > tail_budget { + break; + } + tail_rows += line_row_count; + tail_lines_reversed.push(lines[idx].clone()); + tail_start -= 1; + } + + let mut out = head_lines; let base = omitted_hint.unwrap_or(0); let additional = lines .len() - .saturating_sub(head + tail) + .saturating_sub(out.len() + tail_lines_reversed.len()) .saturating_sub(usize::from(omitted_hint.is_some())); - out.push(Self::ellipsis_line(base + additional)); + out.push(Self::ellipsis_line_with_prefix( + base + additional, + ellipsis_prefix.as_ref(), + )); - if tail > 0 { - out.extend(lines[lines.len() - tail..].iter().cloned()); - } + out.extend(tail_lines_reversed.into_iter().rev()); out } @@ -554,6 +624,14 @@ impl ExecCell { fn ellipsis_line(omitted: usize) -> Line<'static> { Line::from(vec![format!("… +{omitted} lines").dim()]) } + + /// Builds an ellipsis line (`… +N lines`) with an optional leading + /// prefix so the ellipsis aligns with the output gutter. + fn ellipsis_line_with_prefix(omitted: usize, prefix: Option<&Line<'static>>) -> Line<'static> { + let mut line = prefix.cloned().unwrap_or_default(); + line.push_span(format!("… +{omitted} lines").dim()); + line + } } #[derive(Clone, Copy)] @@ -612,20 +690,15 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( mod tests { use super::*; use codex_protocol::protocol::ExecCommandSource; + use pretty_assertions::assert_eq; #[test] fn user_shell_output_is_limited_by_screen_lines() { - // Construct a user shell exec cell whose aggregated output consists of a - // small number of very long logical lines. These will wrap into many - // on-screen lines at narrow widths. - // - // Use a short marker so it survives wrapping intact inside each - // rendered screen line; the previous test used a marker longer than - // the wrap width, so it was split across lines and the assertion - // never actually saw it. - let marker = "Z"; - let long_chunk = marker.repeat(800); - let aggregated_output = format!("{long_chunk}\n{long_chunk}\n"); + let long_url_like = format!( + "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/{}", + "very-long-segment-".repeat(120), + ); + let aggregated_output = format!("{long_url_like}\n{long_url_like}\n"); // Baseline: how many screen lines would we get if we simply wrapped // all logical lines without any truncation? @@ -653,14 +726,18 @@ mod tests { let mut full_wrapped_output: Vec> = Vec::new(); for line in &raw_output.lines { push_owned_lines( - &word_wrap_line(line, output_opts.clone()), + &adaptive_wrap_line(line, output_opts.clone()), &mut full_wrapped_output, ); } - let full_screen_lines = full_wrapped_output - .iter() - .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) - .count(); + let full_prefixed_output = prefix_lines( + full_wrapped_output, + Span::from(layout.output_block.initial_prefix).dim(), + Span::from(layout.output_block.subsequent_prefix), + ); + let full_screen_lines = Paragraph::new(Text::from(full_prefixed_output)) + .wrap(Wrap { trim: false }) + .line_count(width); // Sanity check: this scenario should produce more screen lines than // the user shell per-call limit when no truncation is applied. If @@ -685,21 +762,207 @@ mod tests { // Use a narrow width so each logical line wraps into many on-screen lines. let lines = cell.command_display_lines(width); + let rendered_rows = Paragraph::new(Text::from(lines.clone())) + .wrap(Wrap { trim: false }) + .line_count(width); + let header_rows = Paragraph::new(Text::from(vec![lines[0].clone()])) + .wrap(Wrap { trim: false }) + .line_count(width); + let output_screen_rows = rendered_rows.saturating_sub(header_rows); - // Count how many rendered lines contain our marker text. This approximates - // the number of visible output "screen lines" for this command. - let output_screen_lines = lines + let contains_ellipsis = lines .iter() - .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) - .count(); + .any(|line| line.spans.iter().any(|span| span.content.contains("… +"))); // Regression guard: previously this scenario could render hundreds of - // wrapped lines because truncation happened before wrapping. Now the - // truncation is applied after wrapping, so the number of visible - // screen lines is bounded by USER_SHELL_TOOL_CALL_MAX_LINES. + // wrapped rows because truncation happened before final viewport + // wrapping. The row-aware truncation now caps visible output rows. assert!( - output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES, - "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines of user shell output, got {output_screen_lines}", + output_screen_rows <= USER_SHELL_TOOL_CALL_MAX_LINES, + "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} output rows, got {output_screen_rows} (total rows: {rendered_rows})", + ); + assert!( + contains_ellipsis, + "expected truncated output to include an ellipsis line" + ); + } + + #[test] + fn truncate_lines_middle_keeps_omitted_count_in_line_units() { + let lines = vec![ + Line::from(" └ short"), + Line::from(" this-is-a-very-long-token-that-wraps-many-rows"), + Line::from(" … +4 lines"), + Line::from(" tail"), + ]; + + let truncated = + ExecCell::truncate_lines_middle(&lines, 2, 12, Some(4), Some(Line::from(" ".dim()))); + let rendered: Vec = truncated + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert!( + rendered.iter().any(|line| line.contains("… +6 lines")), + "expected omitted hint to count hidden lines (not wrapped rows), got: {rendered:?}" + ); + } + + #[test] + fn truncate_lines_middle_does_not_truncate_blank_prefixed_output_lines() { + let mut lines = vec![Line::from(" └ start")]; + lines.extend(std::iter::repeat_n(Line::from(" "), 26)); + lines.push(Line::from(" end")); + + let truncated = ExecCell::truncate_lines_middle(&lines, 28, 80, None, None); + + assert_eq!(truncated, lines); + } + + #[test] + fn command_display_does_not_split_long_url_token() { + let url = "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text"; + + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), format!("echo {url}")], + parsed: Vec::new(), + output: None, + source: ExecCommandSource::UserShell, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + let rendered: Vec = cell + .command_display_lines(36) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered.iter().filter(|line| line.contains(url)).count(), + 1, + "expected full URL in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn exploring_display_does_not_split_long_url_like_search_query() { + let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path"; + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "rg foo".into()], + parsed: vec![ParsedCommand::Search { + cmd: format!("rg {url_like}"), + query: Some(url_like.to_string()), + path: None, + }], + output: None, + source: ExecCommandSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + let rendered: Vec = cell + .display_lines(36) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like query in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn output_display_does_not_split_long_url_like_token_without_scheme() { + let url = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/session_id=abc123def456ghi789jkl012mno345pqr678"; + + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo done".into()], + parsed: Vec::new(), + output: Some(CommandOutput { + exit_code: 0, + formatted_output: String::new(), + aggregated_output: url.to_string(), + }), + source: ExecCommandSource::UserShell, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + let rendered: Vec = cell + .command_display_lines(36) + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect(); + + assert_eq!( + rendered.iter().filter(|line| line.contains(url)).count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn desired_transcript_height_accounts_for_wrapped_url_like_rows() { + let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going/for/testing/purposes"; + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo done".into()], + parsed: Vec::new(), + output: Some(CommandOutput { + exit_code: 0, + formatted_output: url.to_string(), + aggregated_output: url.to_string(), + }), + source: ExecCommandSource::Agent, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + let width: u16 = 36; + let logical_height = cell.transcript_lines(width).len() as u16; + let wrapped_height = cell.desired_transcript_height(width); + + assert!( + wrapped_height > logical_height, + "expected transcript height to account for wrapped URL-like rows, logical_height={logical_height}, wrapped_height={wrapped_height}" ); } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index bd08c370e..5ec09c25f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -34,8 +34,8 @@ use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::adaptive_wrap_line; +use crate::wrapping::adaptive_wrap_lines; use base64::Engine; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; @@ -82,9 +82,26 @@ use unicode_width::UnicodeWidthStr; /// Represents an event to display in the conversation history. Returns its /// `Vec>` representation to make it easier to display in a /// scrollable list. +/// A single renderable unit of conversation history. +/// +/// Each cell produces logical `Line`s and reports how many viewport +/// rows those lines occupy at a given terminal width. The default +/// height implementations use `Paragraph::wrap` to account for lines +/// that overflow the viewport width (e.g. long URLs that are kept +/// intact by adaptive wrapping). Concrete types only need to override +/// heights when they apply additional layout logic beyond what +/// `Paragraph::line_count` captures. pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { + /// Returns the logical lines for the main chat viewport. fn display_lines(&self, width: u16) -> Vec>; + /// Returns the number of viewport rows needed to render this cell. + /// + /// The default delegates to `Paragraph::line_count` with + /// `Wrap { trim: false }`, which measures the actual row count after + /// ratatui's viewport-level character wrapping. This is critical + /// for lines containing URL-like tokens that are wider than the + /// terminal — the logical line count would undercount. fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.display_lines(width))) .wrap(Wrap { trim: false }) @@ -93,13 +110,24 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { .unwrap_or(0) } + /// Returns lines for the transcript overlay (`Ctrl+T`). + /// + /// Defaults to `display_lines`. Override when the transcript + /// representation differs (e.g. `ExecCell` shows all calls with + /// `$`-prefixed commands and exit status). fn transcript_lines(&self, width: u16) -> Vec> { self.display_lines(width) } + /// Returns the number of viewport rows for the transcript overlay. + /// + /// Uses the same `Paragraph::line_count` measurement as + /// `desired_height`. Contains a workaround for a ratatui bug where + /// a single whitespace-only line reports 2 rows instead of 1. fn desired_transcript_height(&self, width: u16) -> u16 { let lines = self.transcript_lines(width); - // Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines. + // Workaround: ratatui's line_count returns 2 for a single + // whitespace-only line. Clamp to 1 in that case. if let [line] = &lines[..] && line .spans @@ -138,15 +166,16 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { impl Renderable for Box { fn render(&self, area: Rect, buf: &mut Buffer) { let lines = self.display_lines(area.width); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let y = if area.height == 0 { 0 } else { - let overflow = lines.len().saturating_sub(usize::from(area.height)); + let overflow = paragraph + .line_count(area.width) + .saturating_sub(usize::from(area.height)); u16::try_from(overflow).unwrap_or(u16::MAX) }; - Paragraph::new(Text::from(lines)) - .scroll((y, 0)) - .render(area, buf); + paragraph.scroll((y, 0)).render(area, buf); } fn desired_height(&self, width: u16) -> u16 { HistoryCell::desired_height(self.as_ref(), width) @@ -266,7 +295,7 @@ impl HistoryCell for UserHistoryCell { let wrapped_remote_images = if self.remote_image_urls.is_empty() { None } else { - Some(word_wrap_lines( + Some(adaptive_wrap_lines( self.remote_image_urls .iter() .enumerate() @@ -282,7 +311,7 @@ impl HistoryCell for UserHistoryCell { None } else if self.text_elements.is_empty() { let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); - let wrapped = word_wrap_lines( + let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') .map(|line| Line::from(line).style(style)), @@ -299,7 +328,7 @@ impl HistoryCell for UserHistoryCell { style, element_style, ); - let wrapped = word_wrap_lines( + let wrapped = adaptive_wrap_lines( raw_lines, RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), @@ -336,20 +365,6 @@ impl HistoryCell for UserHistoryCell { lines.push(Line::from("").style(style)); lines } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width) - .len() - .try_into() - .unwrap_or(u16::MAX) - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.display_lines(width) - .len() - .try_into() - .unwrap_or(u16::MAX) - } } #[derive(Debug)] @@ -388,7 +403,7 @@ impl ReasoningSummaryCell { }) .collect::>(); - word_wrap_lines( + adaptive_wrap_lines( &summary_lines, RtOptions::new(width as usize) .initial_indent("• ".dim().into()) @@ -406,21 +421,9 @@ impl HistoryCell for ReasoningSummaryCell { } } - fn desired_height(&self, width: u16) -> u16 { - if self.transcript_only { - 0 - } else { - self.lines(width).len() as u16 - } - } - fn transcript_lines(&self, width: u16) -> Vec> { self.lines(width) } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.lines(width).len() as u16 - } } #[derive(Debug)] @@ -440,7 +443,7 @@ impl AgentMessageCell { impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { - word_wrap_lines( + adaptive_wrap_lines( &self.lines, RtOptions::new(width as usize) .initial_indent(if self.is_first_line { @@ -557,14 +560,7 @@ impl HistoryCell for PrefixedWrappedHistoryCell { let opts = RtOptions::new(width.max(1) as usize) .initial_indent(self.initial_prefix.clone()) .subsequent_indent(self.subsequent_prefix.clone()); - let wrapped = word_wrap_lines(&self.text, opts); - let mut out = Vec::new(); - push_owned_lines(&wrapped, &mut out); - out - } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 + adaptive_wrap_lines(&self.text, opts) } } @@ -605,7 +601,7 @@ impl HistoryCell for UnifiedExecInteractionCell { let header = Line::from(header_spans); let mut out: Vec> = Vec::new(); - let header_wrapped = word_wrap_line(&header, RtOptions::new(wrap_width)); + let header_wrapped = adaptive_wrap_line(&header, RtOptions::new(wrap_width)); push_owned_lines(&header_wrapped, &mut out); if waited_only { @@ -618,7 +614,7 @@ impl HistoryCell for UnifiedExecInteractionCell { .map(|line| Line::from(line.to_string())) .collect(); - let input_wrapped = word_wrap_lines( + let input_wrapped = adaptive_wrap_lines( input_lines, RtOptions::new(wrap_width) .initial_indent(Line::from(" └ ".dim())) @@ -627,10 +623,6 @@ impl HistoryCell for UnifiedExecInteractionCell { out.extend(input_wrapped); out } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 - } } pub(crate) fn new_unified_exec_interaction( @@ -1397,7 +1389,7 @@ impl HistoryCell for McpToolCallCell { let opts = RtOptions::new((width as usize).saturating_sub(4)) .initial_indent("".into()) .subsequent_indent(" ".into()); - let wrapped = word_wrap_line(&invocation_line, opts); + let wrapped = adaptive_wrap_line(&invocation_line, opts); let body_lines: Vec> = wrapped.iter().map(line_to_static).collect(); lines.extend(prefix_lines(body_lines, " └ ".dim(), " ".into())); } @@ -1414,7 +1406,7 @@ impl HistoryCell for McpToolCallCell { let text = Self::render_content_block(block, detail_wrap_width); for segment in text.split('\n') { let line = Line::from(segment.to_string().dim()); - let wrapped = word_wrap_line( + let wrapped = adaptive_wrap_line( &line, RtOptions::new(detail_wrap_width) .initial_indent("".into()) @@ -1432,7 +1424,7 @@ impl HistoryCell for McpToolCallCell { width as usize, ); let err_line = Line::from(err_text.dim()); - let wrapped = word_wrap_line( + let wrapped = adaptive_wrap_line( &err_line, RtOptions::new(detail_wrap_width) .initial_indent("".into()) @@ -1645,11 +1637,9 @@ impl HistoryCell for DeprecationNoticeCell { let wrap_width = width.saturating_sub(4).max(1) as usize; if let Some(details) = &self.details { - let line = textwrap::wrap(details, wrap_width) - .into_iter() - .map(|s| s.to_string().dim().into()) - .collect::>(); - lines.extend(line); + let detail_line = Line::from(details.clone().dim()); + let wrapped = adaptive_wrap_line(&detail_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped, &mut lines); } lines @@ -1974,17 +1964,14 @@ fn wrap_with_prefix( subsequent_prefix: Span<'static>, style: Style, ) -> Vec> { - let prefix_width = initial_prefix - .content - .width() - .max(subsequent_prefix.content.width()); - let wrap_width = width.saturating_sub(prefix_width).max(1); - let wrapped = textwrap::wrap(text, wrap_width); - let wrapped_lines = wrapped - .into_iter() - .map(|segment| Span::from(segment.to_string()).set_style(style).into()) - .collect::>>(); - prefix_lines(wrapped_lines, initial_prefix, subsequent_prefix) + let line = Line::from(vec![Span::from(text.to_string()).set_style(style)]); + let opts = RtOptions::new(width.max(1)) + .initial_indent(Line::from(vec![initial_prefix])) + .subsequent_indent(Line::from(vec![subsequent_prefix])); + let wrapped = adaptive_wrap_line(&line, opts); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out } /// Split a request_user_input answer into option labels and an optional freeform note. @@ -2077,10 +2064,11 @@ impl HistoryCell for PlanUpdateCell { fn display_lines(&self, width: u16) -> Vec> { let render_note = |text: &str| -> Vec> { let wrap_width = width.saturating_sub(4).max(1) as usize; - textwrap::wrap(text, wrap_width) - .into_iter() - .map(|s| s.to_string().dim().italic().into()) - .collect() + let note = Line::from(text.to_string().dim().italic()); + let wrapped = adaptive_wrap_line(¬e, RtOptions::new(wrap_width)); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out }; let render_step = |status: &StepStatus, text: &str| -> Vec> { @@ -2089,16 +2077,15 @@ impl HistoryCell for PlanUpdateCell { StepStatus::InProgress => ("□ ", Style::default().cyan().bold()), StepStatus::Pending => ("□ ", Style::default().dim()), }; - let wrap_width = (width as usize) - .saturating_sub(4) - .saturating_sub(box_str.width()) - .max(1); - let parts = textwrap::wrap(text, wrap_width); - let step_text = parts - .into_iter() - .map(|s| s.to_string().set_style(step_style).into()) - .collect(); - prefix_lines(step_text, box_str.into(), " ".into()) + + let opts = RtOptions::new(width.saturating_sub(4).max(1) as usize) + .initial_indent(box_str.into()) + .subsequent_indent(" ".into()); + let step = Line::from(text.to_string().set_style(step_style)); + let wrapped = adaptive_wrap_line(&step, opts); + let mut out = Vec::new(); + push_owned_lines(&wrapped, &mut out); + out }; let mut lines: Vec> = vec![]; @@ -2713,6 +2700,113 @@ mod tests { ); } + #[test] + fn prefixed_wrapped_history_cell_does_not_split_url_like_token() { + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let cell = PrefixedWrappedHistoryCell::new(Line::from(url_like), "✔ ".green(), " "); + let rendered = render_lines(&cell.display_lines(24)); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn unified_exec_interaction_cell_does_not_split_url_like_stdin_token() { + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let cell = UnifiedExecInteractionCell::new(Some("true".to_string()), url_like.to_string()); + let rendered = render_lines(&cell.display_lines(24)); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(url_like)) + .count(), + 1, + "expected full URL-like token in one rendered line, got: {rendered:?}" + ); + } + + #[test] + fn prefixed_wrapped_history_cell_height_matches_wrapped_rendering() { + let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path"; + let cell: Box = Box::new(PrefixedWrappedHistoryCell::new( + Line::from(url_like), + "✔ ".green(), + " ", + )); + + let width: u16 = 24; + let logical_height = cell.display_lines(width).len() as u16; + let wrapped_height = cell.desired_height(width); + assert!( + wrapped_height > logical_height, + "expected wrapped height to exceed logical line count ({logical_height}), got {wrapped_height}" + ); + + let area = Rect::new(0, 0, width, wrapped_height); + let mut buf = ratatui::buffer::Buffer::empty(area); + cell.render(area, &mut buf); + + let first_row = (0..area.width) + .map(|x| { + let symbol = buf[(x, 0)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::(); + assert!( + first_row.contains("✔"), + "expected first rendered row to keep the prefix visible, got: {first_row:?}" + ); + } + + #[test] + fn unified_exec_interaction_cell_height_matches_wrapped_rendering() { + let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path"; + let cell: Box = Box::new(UnifiedExecInteractionCell::new( + Some("true".to_string()), + url_like.to_string(), + )); + + let width: u16 = 24; + let logical_height = cell.display_lines(width).len() as u16; + let wrapped_height = cell.desired_height(width); + assert!( + wrapped_height > logical_height, + "expected wrapped height to exceed logical line count ({logical_height}), got {wrapped_height}" + ); + + let area = Rect::new(0, 0, width, wrapped_height); + let mut buf = ratatui::buffer::Buffer::empty(area); + cell.render(area, &mut buf); + + let first_row = (0..area.width) + .map(|x| { + let symbol = buf[(x, 0)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::(); + assert!( + first_row.contains("Interacted with"), + "expected first rendered row to keep the header visible, got: {first_row:?}" + ); + } + #[test] fn web_search_history_cell_snapshot() { let query = @@ -3564,6 +3658,55 @@ mod tests { assert!(rendered.iter().any(|line| line.contains("tokenized"))); } + #[test] + fn render_uses_wrapping_for_long_url_like_line() { + let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going/for/testing/purposes-only-and-does/not/need/to/resolve/index.html?session_id=abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"; + let cell: Box = Box::new(UserHistoryCell { + message: url.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }); + + let width: u16 = 52; + let height = cell.desired_height(width); + assert!( + height > 1, + "expected wrapped height for long URL, got {height}" + ); + + let area = Rect::new(0, 0, width, height); + let mut buf = ratatui::buffer::Buffer::empty(area); + cell.render(area, &mut buf); + + let rendered = (0..area.height) + .map(|y| { + (0..area.width) + .map(|x| { + let symbol = buf[(x, y)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::() + }) + .collect::>(); + let rendered_blob = rendered.join("\n"); + + assert!( + rendered_blob.contains("session_id=abc123"), + "expected URL tail to be visible after wrapping, got:\n{rendered_blob}" + ); + + let non_empty_rows = rendered.iter().filter(|row| !row.trim().is_empty()).count() as u16; + assert!( + non_empty_rows > 3, + "expected long URL to span multiple visible rows, got:\n{rendered_blob}" + ); + } + #[test] fn plan_update_with_note_and_wrapping_snapshot() { // Long explanation forces wrapping; include long step text to verify step wrapping and alignment. @@ -3616,6 +3759,43 @@ mod tests { let rendered = render_lines(&lines).join("\n"); insta::assert_snapshot!(rendered); } + + #[test] + fn plan_update_does_not_split_url_like_tokens_in_note_or_step() { + let note_url = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let step_url = "example.test/api/v1/projects/beta-team/releases/2026-02-17/builds/0987654321/artifacts/reports/performance"; + let update = UpdatePlanArgs { + explanation: Some(format!( + "Investigate failures under {note_url} immediately." + )), + plan: vec![PlanItemArg { + step: format!("Validate callbacks under {step_url} before rollout."), + status: StepStatus::InProgress, + }], + }; + + let cell = new_plan_update(update); + let rendered = render_lines(&cell.display_lines(30)); + + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(note_url)) + .count(), + 1, + "expected full note URL-like token in one rendered line, got: {rendered:?}" + ); + assert_eq!( + rendered + .iter() + .filter(|line| line.contains(step_url)) + .count(), + 1, + "expected full step URL-like token in one rendered line, got: {rendered:?}" + ); + } + #[test] fn reasoning_summary_block() { let cell = new_reasoning_summary_block( @@ -3629,6 +3809,50 @@ mod tests { assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]); } + #[test] + fn reasoning_summary_height_matches_wrapped_rendering_for_url_like_content() { + let summary = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going"; + let cell: Box = Box::new(ReasoningSummaryCell::new( + "High level reasoning".to_string(), + summary.to_string(), + false, + )); + let width: u16 = 24; + + let logical_height = cell.display_lines(width).len() as u16; + let wrapped_height = cell.desired_height(width); + let expected_wrapped_height = Paragraph::new(Text::from(cell.display_lines(width))) + .wrap(Wrap { trim: false }) + .line_count(width) as u16; + assert_eq!(wrapped_height, expected_wrapped_height); + assert!( + wrapped_height >= logical_height, + "expected wrapped height to be at least logical line count ({logical_height}), got {wrapped_height}" + ); + + let wrapped_transcript_height = cell.desired_transcript_height(width); + assert_eq!(wrapped_transcript_height, wrapped_height); + + let area = Rect::new(0, 0, width, wrapped_height); + let mut buf = ratatui::buffer::Buffer::empty(area); + cell.render(area, &mut buf); + + let first_row = (0..area.width) + .map(|x| { + let symbol = buf[(x, 0)].symbol(); + if symbol.is_empty() { + ' ' + } else { + symbol.chars().next().unwrap_or(' ') + } + }) + .collect::(); + assert!( + first_row.contains("•"), + "expected first rendered row to keep summary bullet visible, got: {first_row:?}" + ); + } + #[test] fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() { let cell = new_reasoning_summary_block("Detailed reasoning goes here.".to_string()); diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 36ef47da5..9fe4316a9 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -2,9 +2,16 @@ use std::fmt; use std::io; use std::io::Write; -use crate::wrapping::word_wrap_lines_borrowed; +use crate::wrapping::RtOptions; +use crate::wrapping::adaptive_wrap_line; +use crate::wrapping::line_contains_url_like; +use crate::wrapping::line_has_mixed_url_and_non_url_tokens; use crossterm::Command; +use crossterm::cursor::MoveDown; use crossterm::cursor::MoveTo; +use crossterm::cursor::MoveToColumn; +use crossterm::cursor::RestorePosition; +use crossterm::cursor::SavePosition; use crossterm::queue; use crossterm::style::Color as CColor; use crossterm::style::Colors; @@ -38,10 +45,35 @@ where let last_cursor_pos = terminal.last_known_cursor_pos; let writer = terminal.backend_mut(); - // 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; + // Pre-wrap lines for terminal scrollback. Three paths: + // + // - URL-only-ish lines are kept intact (no hard newlines inserted) so that + // terminal emulators can match them as clickable links. The + // terminal will character-wrap these lines at the viewport + // boundary. + // - Mixed lines (URL + non-URL prose) are adaptively wrapped so + // non-URL text still wraps naturally while URL tokens remain + // unsplit. + // - Non-URL lines also flow through adaptive wrapping; behavior is + // equivalent to standard wrapping when no URL is present. + let wrap_width = area.width.max(1) as usize; + let mut wrapped = Vec::new(); + let mut wrapped_rows = 0usize; + + for line in &lines { + let line_wrapped = + if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) { + vec![line.clone()] + } else { + adaptive_wrap_line(line, RtOptions::new(wrap_width)) + }; + wrapped_rows += line_wrapped + .iter() + .map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width)) + .sum::(); + wrapped.extend(line_wrapped); + } + let wrapped_lines = wrapped_rows as u16; 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. @@ -94,6 +126,18 @@ where for line in wrapped { queue!(writer, Print("\r\n"))?; + // URL lines can be wider than the terminal and will + // character-wrap onto continuation rows. Pre-clear those rows + // so stale content from a previously longer line is erased. + let physical_rows = line.width().max(1).div_ceil(wrap_width); + if physical_rows > 1 { + queue!(writer, SavePosition)?; + for _ in 1..physical_rows { + queue!(writer, MoveDown(1), MoveToColumn(0))?; + queue!(writer, Clear(ClearType::UntilNewLine))?; + } + queue!(writer, RestorePosition)?; + } queue!( writer, SetColors(Colors::new( @@ -527,4 +571,163 @@ mod tests { ); } } + + #[test] + fn vt100_prefixed_url_keeps_prefix_and_url_on_same_row() { + let width: u16 = 48; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let url = "http://a-long-url.com/this/that/blablablab/new.aspx/many_people_like_how"; + let line: Line<'static> = Line::from(vec![" │ ".into(), url.into()]); + + insert_history_lines(&mut term, vec![line]).expect("insert history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + + assert!( + rows.iter().any(|r| r.contains("│ http://a-long-url.com")), + "expected prefix and URL on same row, rows: {rows:?}" + ); + assert!( + !rows.iter().any(|r| r.trim_end() == "│"), + "unexpected orphan prefix row, rows: {rows:?}" + ); + } + + #[test] + fn vt100_prefixed_url_like_without_scheme_keeps_prefix_and_token_on_same_row() { + let width: u16 = 48; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let line: Line<'static> = Line::from(vec![" │ ".into(), url_like.into()]); + + insert_history_lines(&mut term, vec![line]).expect("insert history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + + assert!( + rows.iter() + .any(|r| r.contains("│ example.test/api/v1/projects")), + "expected prefix and URL-like token on same row, rows: {rows:?}" + ); + assert!( + !rows.iter().any(|r| r.trim_end() == "│"), + "unexpected orphan prefix row, rows: {rows:?}" + ); + } + + #[test] + fn vt100_prefixed_mixed_url_line_wraps_suffix_words_together() { + let width: u16 = 24; + let height: u16 = 10; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let url = "https://example.test/path/abcdef12345"; + let line: Line<'static> = Line::from(vec![ + " │ ".into(), + "see ".into(), + url.into(), + " tail words".into(), + ]); + + insert_history_lines(&mut term, vec![line]).expect("insert mixed history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + assert!( + rows.iter().any(|r| r.contains("│ see")), + "expected prefixed prose before URL, rows: {rows:?}" + ); + assert!( + rows.iter().any(|r| r.contains("tail words")), + "expected suffix words to wrap as a phrase, rows: {rows:?}" + ); + } + + #[test] + fn vt100_unwrapped_url_like_clears_continuation_rows() { + let width: u16 = 20; + let height: u16 = 10; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let filler_line: Line<'static> = Line::from(vec![ + " │ ".into(), + "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX".into(), + ]); + insert_history_lines(&mut term, vec![filler_line]).expect("insert filler history"); + + let url_like = "example.test/api/v1/short"; + let url_line: Line<'static> = Line::from(vec![" │ ".into(), url_like.into()]); + insert_history_lines(&mut term, vec![url_line]).expect("insert url-like history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + let first_row = rows + .iter() + .position(|row| row.contains("│ example.test/api")) + .unwrap_or_else(|| panic!("expected url-like first row in screen rows: {rows:?}")); + assert!( + first_row + 1 < rows.len(), + "expected a continuation row for wrapped URL-like line, rows: {rows:?}" + ); + let continuation_row = rows[first_row + 1].trim_end(); + + assert!( + continuation_row.contains("/v1/short") || continuation_row.contains("short"), + "expected continuation row to contain wrapped URL-like tail, got: {continuation_row:?}" + ); + assert!( + !continuation_row.contains('X'), + "expected continuation row to be cleared before writing wrapped URL-like content, got: {continuation_row:?}" + ); + } + + #[test] + fn vt100_long_unwrapped_url_does_not_insert_extra_blank_gap_before_content() { + let width: u16 = 56; + let height: u16 = 24; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, height - 1, width, 1); + term.set_viewport_area(viewport); + + let prompt = "Write a long URL as output for testing"; + insert_history_lines(&mut term, vec![Line::from(prompt)]).expect("insert prompt line"); + + let long_url = format!( + "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/{}", + "very-long-segment-".repeat(16), + ); + let url_line: Line<'static> = Line::from(vec!["• ".into(), long_url.into()]); + insert_history_lines(&mut term, vec![url_line]).expect("insert long url line"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + let prompt_row = rows + .iter() + .position(|row| row.contains("Write a long URL as output for testing")) + .unwrap_or_else(|| panic!("expected prompt row in screen rows: {rows:?}")); + let url_row = rows + .iter() + .position(|row| row.contains("• https://example.test/api")) + .unwrap_or_else(|| panic!("expected URL first row in screen rows: {rows:?}")); + + assert!( + url_row <= prompt_row + 2, + "expected URL content to appear immediately after prompt (allowing at most one spacer row), got prompt_row={prompt_row}, url_row={url_row}, rows={rows:?}", + ); + } } diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 4ba7b6131..35b0e8f5f 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1,6 +1,6 @@ use crate::render::line_utils::line_to_static; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; +use crate::wrapping::adaptive_wrap_line; use pulldown_cmark::CodeBlockKind; use pulldown_cmark::CowStr; use pulldown_cmark::Event; @@ -446,7 +446,7 @@ where let opts = RtOptions::new(width) .initial_indent(self.current_initial_indent.clone().into()) .subsequent_indent(self.current_subsequent_indent.clone().into()); - for wrapped in word_wrap_line(&line, opts) { + for wrapped in adaptive_wrap_line(&line, opts) { let owned = line_to_static(&wrapped).style(style); self.text.lines.push(owned); } @@ -675,4 +675,18 @@ mod tests { vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),] ); } + + #[test] + fn does_not_split_long_url_like_token_without_scheme() { + let url_like = + "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890"; + let rendered = render_markdown_text_with_width(url_like, Some(24)); + let lines = lines_to_strings(&rendered); + + assert_eq!( + lines.iter().filter(|line| line.contains(url_like)).count(), + 1, + "expected full URL-like token in one rendered line, got: {lines:?}" + ); + } } diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index f8b240e16..9c28cc1ea 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -323,7 +323,11 @@ impl ModelMigrationScreen { let wrap_width = (content_width > 0).then_some(content_width as usize); let rendered = render_markdown_text_with_width(markdown, wrap_width); for line in rendered.lines { - column.push(line.inset(Insets::tlbr(0, horizontal_inset, 0, 0))); + column.push( + Paragraph::new(line) + .wrap(Wrap { trim: false }) + .inset(Insets::tlbr(0, horizontal_inset, 0, 0)), + ); } } @@ -393,6 +397,7 @@ fn fill_migration_markdown(template: &str, current_model: &str, target_model: &s #[cfg(test)] mod tests { + use super::ModelMigrationCopy; use super::ModelMigrationScreen; use super::migration_copy_for_models; use crate::custom_terminal::Terminal; @@ -578,4 +583,34 @@ mod tests { super::ModelMigrationOutcome::Rejected )); } + + #[test] + fn markdown_prompt_keeps_long_url_tail_visible_when_narrow() { + let long_url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42"; + let screen = ModelMigrationScreen::new( + FrameRequester::test_dummy(), + ModelMigrationCopy { + heading: Vec::new(), + content: Vec::new(), + can_opt_out: false, + markdown: Some(long_url.to_string()), + }, + ); + + let backend = VT100Backend::new(40, 16); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, 40, 16)); + + { + let mut frame = terminal.get_frame(); + frame.render_widget_ref(&screen, frame.area()); + } + terminal.flush().expect("flush"); + + let rendered = terminal.backend().to_string(); + assert!( + rendered.contains("tail42"), + "expected wrapped markdown URL tail to remain visible, got:\n{rendered}" + ); + } } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index c639d24a0..23b1ff4aa 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -39,6 +39,41 @@ use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; + +/// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink. +/// +/// Terminal emulators recognise the OSC 8 escape sequence and treat the entire +/// marked region as a single clickable link, regardless of row wrapping. This +/// is necessary because ratatui's cell-based rendering emits `MoveTo` at every +/// row boundary, which breaks normal terminal URL detection for long URLs that +/// wrap across multiple rows. +pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { + // Sanitize: strip any characters that could break out of the OSC 8 + // sequence (ESC or BEL) to prevent terminal escape injection from a + // malformed or compromised upstream URL. + let safe_url: String = url + .chars() + .filter(|&c| c != '\x1B' && c != '\x07') + .collect(); + if safe_url.is_empty() { + return; + } + + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + let cell = &mut buf[(x, y)]; + // Only mark cells that carry the URL's distinctive style. + if cell.fg != Color::Cyan || !cell.modifier.contains(Modifier::UNDERLINED) { + continue; + } + let sym = cell.symbol().to_string(); + if sym.trim().is_empty() { + continue; + } + cell.set_symbol(&format!("\x1B]8;;{safe_url}\x07{sym}\x1B]8;;\x07")); + } + } +} use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Notify; @@ -370,7 +405,7 @@ impl AuthModeWidget { let mut lines = vec![spans.into(), "".into()]; let sign_in_state = self.sign_in_state.read().unwrap(); - if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state + let auth_url = if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); @@ -386,12 +421,21 @@ impl AuthModeWidget { ".".into(), ])); lines.push("".into()); - } + Some(state.auth_url.clone()) + } else { + None + }; lines.push(" Press Esc to cancel".dim().into()); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); + + // Wrap cyan+underlined URL cells with OSC 8 so the terminal treats + // the entire region as a single clickable hyperlink. + if let Some(url) = &auth_url { + mark_url_hyperlink(buf, area, url); + } } fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) { @@ -842,4 +886,98 @@ mod tests { )); assert_eq!(widget.login_status, LoginStatus::NotAuthenticated); } + + /// Collects all buffer cell symbols that contain the OSC 8 open sequence + /// for the given URL. Returns the concatenated "inner" characters. + fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String { + let open = format!("\x1B]8;;{url}\x07"); + let close = "\x1B]8;;\x07"; + let mut chars = String::new(); + for y in area.top()..area.bottom() { + for x in area.left()..area.right() { + let sym = buf[(x, y)].symbol(); + if let Some(rest) = sym.strip_prefix(open.as_str()) + && let Some(ch) = rest.strip_suffix(close) + { + chars.push_str(ch); + } + } + } + chars + } + + #[test] + fn continue_in_browser_renders_osc8_hyperlink() { + let (widget, _tmp) = widget_forced_chatgpt(); + let url = "https://auth.example.com/login?state=abc123"; + *widget.sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url: url.to_string(), + shutdown_flag: None, + }); + + // Render into a narrow buffer so the URL wraps across multiple rows. + let area = Rect::new(0, 0, 30, 20); + 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"); + } + + #[test] + fn mark_url_hyperlink_wraps_cyan_underlined_cells() { + let url = "https://example.com"; + let area = Rect::new(0, 0, 20, 1); + let mut buf = Buffer::empty(area); + + // Manually write some cyan+underlined characters to simulate a rendered URL. + for (i, ch) in "example".chars().enumerate() { + let cell = &mut buf[(i as u16, 0)]; + cell.set_symbol(&ch.to_string()); + cell.fg = Color::Cyan; + cell.modifier = Modifier::UNDERLINED; + } + // Leave a plain cell that should NOT be marked. + buf[(7, 0)].set_symbol("X"); + + mark_url_hyperlink(&mut buf, area, url); + + // Each cyan+underlined cell should now carry the OSC 8 wrapper. + let found = collect_osc8_chars(&buf, area, url); + assert_eq!(found, "example"); + + // The plain "X" cell should be untouched. + assert_eq!(buf[(7, 0)].symbol(), "X"); + } + + #[test] + fn mark_url_hyperlink_sanitizes_control_chars() { + let area = Rect::new(0, 0, 10, 1); + let mut buf = Buffer::empty(area); + + // One cyan+underlined cell to mark. + let cell = &mut buf[(0, 0)]; + cell.set_symbol("a"); + cell.fg = Color::Cyan; + cell.modifier = Modifier::UNDERLINED; + + // URL contains ESC and BEL that could break the OSC 8 sequence. + let malicious_url = "https://evil.com/\x1B]8;;\x07injected"; + mark_url_hyperlink(&mut buf, area, malicious_url); + + let sym = buf[(0, 0)].symbol().to_string(); + // The sanitized URL retains `]` (printable) but strips ESC and BEL. + let sanitized = "https://evil.com/]8;;injected"; + assert!( + sym.contains(sanitized), + "symbol should contain sanitized URL, got: {sym:?}" + ); + // The injected close-sequence must not survive: \x1B and \x07 are gone. + assert!( + !sym.contains("\x1B]8;;\x07injected"), + "symbol must not contain raw control chars from URL" + ); + } } diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs index f4949fe13..c8a634584 100644 --- a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -21,6 +21,7 @@ use super::AuthModeWidget; use super::ContinueInBrowserState; use super::ContinueWithDeviceCodeState; use super::SignInState; +use super::mark_url_hyperlink; pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) { opts.open_browser = false; @@ -153,7 +154,8 @@ pub(super) fn render_device_code_login( let mut lines = vec![spans.into(), "".into()]; - if let Some(device_code) = &state.device_code { + // Capture the verification URL for OSC 8 hyperlink marking after render. + let verification_url = if let Some(device_code) = &state.device_code { lines.push(" 1. Open this link in your browser and sign in".into()); lines.push("".into()); lines.push(Line::from(vec![ @@ -176,15 +178,23 @@ pub(super) fn render_device_code_login( .into(), ); lines.push("".into()); + Some(device_code.verification_url.clone()) } else { lines.push(" Requesting a one-time code...".dim().into()); lines.push("".into()); - } + None + }; lines.push(" Press Esc to cancel".dim().into()); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); + + // Wrap cyan+underlined URL cells with OSC 8 so the terminal treats + // the entire region as a single clickable hyperlink. + if let Some(url) = &verification_url { + mark_url_hyperlink(buf, area, url); + } } fn device_code_attempt_matches(state: &SignInState, cancel: &Arc) -> bool { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 00c18dd71..a6d415da1 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -409,8 +409,9 @@ struct CellRenderable { impl Renderable for CellRenderable { fn render(&self, area: Rect, buf: &mut Buffer) { - let p = - Paragraph::new(Text::from(self.cell.transcript_lines(area.width))).style(self.style); + let p = Paragraph::new(Text::from(self.cell.transcript_lines(area.width))) + .style(self.style) + .wrap(Wrap { trim: false }); p.render(area, buf); } @@ -645,7 +646,7 @@ impl TranscriptOverlay { has_prior_cells: bool, is_stream_continuation: bool, ) -> Box { - let paragraph = Paragraph::new(Text::from(lines)); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); let mut renderable: Box = Box::new(CachedRenderable::new(paragraph)); if has_prior_cells && !is_stream_continuation { renderable = Box::new(InsetRenderable::new(renderable, Insets::tlbr(1, 0, 0, 0))); diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap index 27649a11d..3c5a1526b 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap @@ -5,8 +5,8 @@ expression: rendered • Updated Plan └ I’ll update Grafana call error handling by adding - retries and clearer - messages when the backend is + retries and clearer messages + when the backend is unreachable. ✔ Investigate existing error paths and logging around diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index bf55c0ace..acfa604e9 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -41,7 +41,7 @@ use super::rate_limits::compose_rate_limit_data_many; use super::rate_limits::format_status_limit_summary; use super::rate_limits::render_status_limit_progress_bar; use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_lines; +use crate::wrapping::adaptive_wrap_lines; use codex_core::AuthManager; #[derive(Debug, Clone)] @@ -479,7 +479,7 @@ impl HistoryCell for StatusHistoryCell { let note_second_line = Line::from(vec![ Span::from("information on rate limits and credits").cyan(), ]); - let note_lines = word_wrap_lines( + let note_lines = adaptive_wrap_lines( [note_first_line, note_second_line], RtOptions::new(available_inner_width), ); diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index c29106651..f042fbbc9 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -1,3 +1,31 @@ +//! Word-wrapping with URL-aware heuristics. +//! +//! The TUI renders text that frequently contains URLs — command output, +//! markdown, agent messages, tool-call results. Standard `textwrap` +//! hyphenation treats `/` and `-` as split points, which breaks URLs +//! across lines and makes them unclickable in terminal emulators. +//! +//! This module provides two wrapping paths: +//! +//! - **Standard** (`word_wrap_line`, `word_wrap_lines`): delegates to +//! `textwrap` with the caller's options unchanged. Used when the +//! content is known to be plain prose. +//! - **Adaptive** (`adaptive_wrap_line`, `adaptive_wrap_lines`): +//! inspects the line for URL-like tokens; if any are found, the +//! wrapping switches to `AsciiSpace` word separation and a custom +//! `WordSplitter` that refuses to split URL tokens. Non-URL tokens +//! on the same line still break at every character boundary (the +//! custom splitter returns all char indices for non-URL words). +//! +//! Callers that *might* encounter URLs should use the `adaptive_*` +//! functions. Callers that definitely will not (code blocks, pure +//! numeric output) can use the standard path for speed. +//! +//! URL detection is heuristic — see [`text_contains_url_like`] for the +//! rules. False positives suppress hyphenation for that line; false +//! negatives let a URL get split. The heuristic is intentionally +//! conservative: file paths like `src/main.rs` are not matched. + use ratatui::text::Line; use ratatui::text::Span; use std::borrow::Cow; @@ -6,12 +34,16 @@ use textwrap::Options; use crate::render::line_utils::push_owned_lines; +/// Returns byte-ranges into `text` for each wrapped line, including +/// trailing whitespace and a +1 sentinel byte. Used by the textarea +/// cursor-position logic. pub(crate) fn wrap_ranges<'a, O>(text: &str, width_or_options: O) -> Vec> where O: Into>, { let opts = width_or_options.into(); let mut lines: Vec> = Vec::new(); + let mut cursor = 0usize; for line in textwrap::wrap(text, opts).iter() { match line { std::borrow::Cow::Borrowed(slice) => { @@ -19,8 +51,14 @@ where let end = start + slice.len(); let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count(); lines.push(start..end + trailing_spaces + 1); + cursor = end + trailing_spaces; + } + std::borrow::Cow::Owned(slice) => { + let mapped = map_owned_wrapped_line_to_range(text, cursor, slice); + let trailing_spaces = text[mapped.end..].chars().take_while(|c| *c == ' ').count(); + lines.push(mapped.start..mapped.end + trailing_spaces + 1); + cursor = mapped.end + trailing_spaces; } - std::borrow::Cow::Owned(_) => panic!("wrap_ranges: unexpected owned string"), } } lines @@ -35,19 +73,429 @@ where { let opts = width_or_options.into(); let mut lines: Vec> = Vec::new(); + let mut cursor = 0usize; for line in textwrap::wrap(text, opts).iter() { match line { std::borrow::Cow::Borrowed(slice) => { let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize }; let end = start + slice.len(); lines.push(start..end); + cursor = end; + } + std::borrow::Cow::Owned(slice) => { + let mapped = map_owned_wrapped_line_to_range(text, cursor, slice); + lines.push(mapped.clone()); + cursor = mapped.end; } - std::borrow::Cow::Owned(_) => panic!("wrap_ranges_trim: unexpected owned string"), } } lines } +/// Maps an owned (materialized) wrapped line back to a byte range in `text`. +/// +/// `textwrap` returns `Cow::Owned` when it inserts a hyphenation penalty +/// character (typically `-`) that does not exist in the source. This +/// function walks the owned string character-by-character against the +/// source, skipping trailing penalty chars, and returns the +/// corresponding source byte range starting from `cursor`. +fn map_owned_wrapped_line_to_range(text: &str, cursor: usize, wrapped: &str) -> Range { + let mut start = cursor; + while start < text.len() && !wrapped.starts_with(' ') { + let Some(ch) = text[start..].chars().next() else { + break; + }; + if ch != ' ' { + break; + } + start += ch.len_utf8(); + } + + let mut end = start; + let mut chars = wrapped.chars().peekable(); + while let Some(ch) = chars.next() { + if end < text.len() { + let Some(src) = text[end..].chars().next() else { + unreachable!("checked end < text.len()"); + }; + if ch == src { + end += src.len_utf8(); + continue; + } + } + + // textwrap can materialize owned lines when penalties are inserted. + // The default penalty is a trailing '-'; it does not correspond to + // source bytes, so we skip it while keeping byte ranges in source text. + if ch == '-' && chars.peek().is_none() { + continue; + } + + panic!("wrap_ranges: could not map owned line {wrapped:?} to source near byte {cursor}"); + } + + start..end +} + +/// Returns `true` if any whitespace-delimited token in `line` looks like a URL. +/// +/// Concatenates all span contents and delegates to [`text_contains_url_like`]. +pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool { + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + text_contains_url_like(&text) +} + +/// Returns `true` if `line` contains both a URL-like token and at least one +/// substantive non-URL token. +/// +/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`, +/// `│`) are ignored for the non-URL side of this check. +pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool { + let text: String = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect(); + text_has_mixed_url_and_non_url_tokens(&text) +} + +/// Returns `true` if any whitespace-delimited token in `text` looks like a URL. +/// +/// Recognized patterns: +/// - Absolute URLs with a scheme (`https://…`, `ftp://…`, custom `myapp://…`). +/// - Bare domain URLs (`example.com/path`, `www.example.com`, `localhost:3000/api`). +/// - IPv4 hosts with a path (`192.168.1.1:8080/health`). +/// +/// Surrounding punctuation (`()[]{}< >,.;:!'"`) is stripped before +/// checking. Tokens that look like file paths (`src/main.rs`, `foo/bar`) +/// are intentionally rejected — the host portion must be a valid domain +/// name (with a recognized TLD), an IPv4 address, or `localhost`. +pub(crate) fn text_contains_url_like(text: &str) -> bool { + text.split_ascii_whitespace().any(is_url_like_token) +} + +/// Returns `true` if `text` contains at least one URL-like token and at least +/// one substantive non-URL token. +fn text_has_mixed_url_and_non_url_tokens(text: &str) -> bool { + let mut saw_url = false; + let mut saw_non_url = false; + + for raw_token in text.split_ascii_whitespace() { + if is_url_like_token(raw_token) { + saw_url = true; + } else if is_substantive_non_url_token(raw_token) { + saw_non_url = true; + } + + if saw_url && saw_non_url { + return true; + } + } + + false +} + +/// Decides whether a single whitespace-delimited token is URL-like. +/// +/// Strips surrounding punctuation, then checks for an absolute URL +/// (with `://`) or a bare domain URL (recognized host + path/query/fragment). +fn is_url_like_token(raw_token: &str) -> bool { + let token = trim_url_token(raw_token); + !token.is_empty() && (is_absolute_url_like(token) || is_bare_url_like(token)) +} + +fn is_substantive_non_url_token(raw_token: &str) -> bool { + let token = trim_url_token(raw_token); + if token.is_empty() || is_decorative_marker_token(raw_token, token) { + return false; + } + + token.chars().any(char::is_alphanumeric) +} + +fn is_decorative_marker_token(raw_token: &str, token: &str) -> bool { + let raw = raw_token.trim(); + matches!( + raw, + "-" | "*" + | "+" + | "•" + | "◦" + | "▪" + | ">" + | "|" + | "│" + | "┆" + | "└" + | "├" + | "┌" + | "┐" + | "┘" + | "┼" + ) || is_ordered_list_marker(raw, token) +} + +fn is_ordered_list_marker(raw_token: &str, token: &str) -> bool { + token.chars().all(|c| c.is_ascii_digit()) + && (raw_token.ends_with('.') || raw_token.ends_with(')')) +} + +fn trim_url_token(token: &str) -> &str { + token.trim_matches(|c: char| { + matches!( + c, + '(' | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ',' + | '.' + | ';' + | ':' + | '!' + | '\'' + | '"' + ) + }) +} + +/// Checks for `scheme://host` patterns. Uses `url::Url::parse` for +/// well-known schemes; falls back to `has_valid_scheme_prefix` for +/// custom schemes that the `url` crate rejects. +fn is_absolute_url_like(token: &str) -> bool { + if !token.contains("://") { + return false; + } + + if let Ok(url) = url::Url::parse(token) { + let scheme = url.scheme().to_ascii_lowercase(); + if matches!( + scheme.as_str(), + "http" | "https" | "ftp" | "ftps" | "ws" | "wss" + ) { + return url.host_str().is_some(); + } + return true; + } + + has_valid_scheme_prefix(token) +} + +fn has_valid_scheme_prefix(token: &str) -> bool { + let Some((scheme, rest)) = token.split_once("://") else { + return false; + }; + if scheme.is_empty() || rest.is_empty() { + return false; + } + + let mut chars = scheme.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_alphabetic() + && chars.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') +} + +/// Checks for bare-domain URLs without a scheme: `host[:port]/path`, +/// `host[:port]?query`, or `host[:port]#fragment`. +/// +/// Requires that the host is `localhost`, an IPv4 address, or a valid +/// domain name. Bare `host.tld` without a path/query/fragment is only +/// accepted when the host starts with `www.`. +/// +/// IPv6 bracket notation (`[::1]:8080`) is intentionally not handled. +fn is_bare_url_like(token: &str) -> bool { + let (host_port, has_trailer) = split_host_port_and_trailer(token); + if host_port.is_empty() { + return false; + } + + // Require URL-ish trailer for bare hosts unless token starts with www. + if !has_trailer && !host_port.to_ascii_lowercase().starts_with("www.") { + return false; + } + + let (host, port) = split_host_and_port(host_port); + if host.is_empty() { + return false; + } + if let Some(port) = port + && !is_valid_port(port) + { + return false; + } + + host.eq_ignore_ascii_case("localhost") || is_ipv4(host) || is_domain_name(host) +} + +fn split_host_port_and_trailer(token: &str) -> (&str, bool) { + if let Some(idx) = token.find(['/', '?', '#']) { + (&token[..idx], true) + } else { + (token, false) + } +} + +fn split_host_and_port(host_port: &str) -> (&str, Option<&str>) { + // We intentionally do not treat bracketed IPv6 as URL-like in this first pass. + if host_port.starts_with('[') { + return (host_port, None); + } + + if let Some((host, port)) = host_port.rsplit_once(':') + && !host.is_empty() + && !port.is_empty() + && port.chars().all(|c| c.is_ascii_digit()) + { + return (host, Some(port)); + } + + (host_port, None) +} + +fn is_valid_port(port: &str) -> bool { + if port.is_empty() || port.len() > 5 || !port.chars().all(|c| c.is_ascii_digit()) { + return false; + } + + port.parse::().is_ok() +} + +fn is_ipv4(host: &str) -> bool { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() != 4 { + return false; + } + + parts + .iter() + .all(|part| !part.is_empty() && part.parse::().is_ok()) +} + +fn is_domain_name(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + if !host.contains('.') { + return false; + } + + let mut labels = host.split('.'); + let Some(tld) = labels.next_back() else { + return false; + }; + if !is_tld(tld) { + return false; + } + + labels.all(is_domain_label) +} + +fn is_tld(label: &str) -> bool { + (2..=63).contains(&label.len()) && label.chars().all(|c| c.is_ascii_alphabetic()) +} + +fn is_domain_label(label: &str) -> bool { + if label.is_empty() || label.len() > 63 { + return false; + } + + let mut chars = label.chars(); + let Some(first) = chars.next() else { + return false; + }; + let Some(last) = label.chars().next_back() else { + return false; + }; + + first.is_ascii_alphanumeric() + && last.is_ascii_alphanumeric() + && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +/// Reconfigures wrapping options so that URL-like tokens are never split. +/// +/// Sets `AsciiSpace` word separation (so `/` and `-` inside URLs are +/// not treated as break points), disables `break_words`, and installs a +/// custom `WordSplitter` that returns no split points for URL tokens +/// while still allowing character-level splitting for non-URL words. +pub(crate) fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<'a> { + opts.word_separator(textwrap::WordSeparator::AsciiSpace) + .word_splitter(textwrap::WordSplitter::Custom(split_non_url_word)) + .break_words(false) +} + +/// Custom `textwrap::WordSplitter` callback. Returns empty (no split +/// points) for URL-like tokens so they are kept intact; returns every +/// char-boundary index for everything else so non-URL words can still +/// break at any position. +fn split_non_url_word(word: &str) -> Vec { + if is_url_like_token(word) { + return Vec::new(); + } + + word.char_indices().skip(1).map(|(idx, _)| idx).collect() +} + +/// Wraps a single ratatui `Line`, automatically switching to +/// URL-preserving options when the line contains a URL-like token. +/// +/// When no URL is detected, wrapping behavior is identical to +/// [`word_wrap_line`]. When a URL is detected, the line is wrapped with +/// [`url_preserving_wrap_options`] — URLs stay intact while non-URL +/// words on the same line still break normally. +#[must_use] +pub(crate) fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec> { + let selected = if line_contains_url_like(line) { + url_preserving_wrap_options(base) + } else { + base + }; + word_wrap_line(line, selected) +} + +/// Wraps multiple input lines with URL-aware heuristics, applying +/// `initial_indent` to the first line and `subsequent_indent` to the +/// rest. Each line is independently checked for URLs; URL detection on +/// one line does not affect wrapping of the others. +/// +/// This is the multi-line counterpart to [`adaptive_wrap_line`] and is +/// the primary wrapping entry point for most history-cell rendering. +#[allow(private_bounds)] +pub(crate) fn adaptive_wrap_lines<'a, I, L>( + lines: I, + width_or_options: RtOptions<'a>, +) -> Vec> +where + I: IntoIterator, + L: IntoLineInput<'a>, +{ + let base_opts = width_or_options; + let mut out: Vec> = Vec::new(); + + for (idx, line) in lines.into_iter().enumerate() { + let line_input = line.into_line_input(); + let opts = if idx == 0 { + base_opts.clone() + } else { + base_opts + .clone() + .initial_indent(base_opts.subsequent_indent.clone()) + }; + + let wrapped = adaptive_wrap_line(line_input.as_ref(), opts); + push_owned_lines(&wrapped, &mut out); + } + + out +} + #[derive(Debug, Clone)] pub struct RtOptions<'a> { /// The width in columns at which the text will be wrapped. @@ -644,4 +1092,162 @@ the kindness of the woman who tended them."# ); } + + #[test] + fn ascii_space_separator_with_no_hyphenation_keeps_url_intact() { + let line = Line::from( + "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text", + ); + let opts = RtOptions::new(24) + .word_separator(textwrap::WordSeparator::AsciiSpace) + .word_splitter(textwrap::WordSplitter::NoHyphenation) + .break_words(false); + + let out = word_wrap_line(&line, opts); + + assert_eq!(out.len(), 1); + assert_eq!( + concat_line(&out[0]), + "http://example.com/long-url-with-dashes-wider-than-terminal-window/blah-blah-blah-text/more-gibberish-text" + ); + } + + #[test] + fn text_contains_url_like_matches_expected_tokens() { + let positives = [ + "https://example.com/a/b", + "ftp://host/path", + "www.example.com/path?x=1", + "example.test/path#frag", + "localhost:3000/api", + "127.0.0.1:8080/health", + "(https://example.com/wrapped-in-parens)", + ]; + + for text in positives { + assert!( + text_contains_url_like(text), + "expected URL-like match for {text:?}" + ); + } + } + + #[test] + fn text_contains_url_like_rejects_non_urls() { + let negatives = [ + "src/main.rs", + "foo/bar", + "key:value", + "just-some-text-with-dashes", + "hello.world", // no path/query/fragment and no www + ]; + + for text in negatives { + assert!( + !text_contains_url_like(text), + "did not expect URL-like match for {text:?}" + ); + } + } + + #[test] + fn line_contains_url_like_checks_across_spans() { + let line = Line::from(vec![ + "see ".into(), + "https://example.com/a/very/long/path".cyan(), + " for details".into(), + ]); + + assert!(line_contains_url_like(&line)); + } + + #[test] + fn line_has_mixed_url_and_non_url_tokens_detects_prose_plus_url() { + let line = Line::from("see https://example.com/path for details"); + assert!(line_has_mixed_url_and_non_url_tokens(&line)); + } + + #[test] + fn line_has_mixed_url_and_non_url_tokens_ignores_pipe_prefix() { + let line = Line::from(vec![" │ ".into(), "https://example.com/path".into()]); + assert!(!line_has_mixed_url_and_non_url_tokens(&line)); + } + + #[test] + fn line_has_mixed_url_and_non_url_tokens_ignores_ordered_list_marker() { + let line = Line::from("1. https://example.com/path"); + assert!(!line_has_mixed_url_and_non_url_tokens(&line)); + } + + #[test] + fn text_contains_url_like_accepts_custom_scheme_with_separator() { + assert!(text_contains_url_like("myapp://open/some/path")); + } + + #[test] + fn text_contains_url_like_rejects_invalid_ports() { + assert!(!text_contains_url_like("localhost:99999/path")); + assert!(!text_contains_url_like("example.com:abc/path")); + } + + #[test] + fn adaptive_wrap_line_keeps_long_url_like_token_intact() { + let line = Line::from("example.test/a-very-long-path-with-many-segments-and-query?x=1&y=2"); + let out = adaptive_wrap_line(&line, RtOptions::new(20)); + assert_eq!(out.len(), 1); + assert_eq!( + concat_line(&out[0]), + "example.test/a-very-long-path-with-many-segments-and-query?x=1&y=2" + ); + } + + #[test] + fn adaptive_wrap_line_preserves_default_behavior_for_non_url_tokens() { + let line = Line::from("a_very_long_token_without_spaces_to_force_wrapping"); + let out = adaptive_wrap_line(&line, RtOptions::new(20)); + assert!( + out.len() > 1, + "expected non-url token to wrap with default options" + ); + } + + #[test] + fn adaptive_wrap_line_mixed_line_wraps_long_non_url_token() { + let long_non_url = "a_very_long_token_without_spaces_to_force_wrapping"; + let line = Line::from(format!("see https://ex.com {long_non_url}")); + let out = adaptive_wrap_line(&line, RtOptions::new(24)); + + assert!( + out.iter() + .any(|line| concat_line(line).contains("https://ex.com")), + "expected URL token to remain present, got: {out:?}" + ); + assert!( + !out.iter() + .any(|line| concat_line(line).contains(long_non_url)), + "expected long non-url token to wrap on mixed lines, got: {out:?}" + ); + } + + #[test] + fn wrap_ranges_trim_handles_owned_lines_with_penalty_char() { + fn split_every_char(word: &str) -> Vec { + word.char_indices().skip(1).map(|(idx, _)| idx).collect() + } + + let text = "a_very_long_token_without_spaces"; + let opts = Options::new(8) + .word_separator(textwrap::WordSeparator::AsciiSpace) + .word_splitter(textwrap::WordSplitter::Custom(split_every_char)) + .break_words(false); + + let ranges = wrap_ranges_trim(text, opts); + let rebuilt = ranges + .iter() + .map(|range| &text[range.clone()]) + .collect::(); + + assert_eq!(rebuilt, text); + assert!(ranges.len() > 1, "expected wrapped ranges, got: {ranges:?}"); + } }