From 2ba2c57af4b548512a749a6947341452a16b3521 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 21 Feb 2026 20:31:41 -0300 Subject: [PATCH] fix(tui): preserve URL clickability across all TUI views (#12067) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Long URLs containing `/` and `-` characters are split across multiple terminal lines by `textwrap`'s default hyphenation rules. This breaks terminal link detection: emulators can no longer identify the URL as clickable, and copy-paste yields a truncated fragment. The issue affects every view that renders user or agent text — exec output, history cells, markdown, the app-link setup screen, and the VT100 scrollback path. A secondary bug compounds the first: `desired_height()` calculations count logical lines rather than viewport rows. When a URL overflows its line and wraps visually, the height budget is too small, causing content to clip or leave gaps. Here is how the complete URL is interpreted by the terminal before (first line only) and after (complete URL): | Before | After | |---|---| | Screenshot 2026-02-17 at 7 59 11
PM | Screenshot 2026-02-17 at 7 58
40 PM | ## Mental model The TUI now treats URL-like tokens as atomic units that must never be split by the wrapping engine. Every call site that previously used `word_wrap_*` has been migrated to `adaptive_wrap_*`, which inspects each line for URL-like tokens and switches wrapping strategy accordingly: - **Non-URL lines** follow the existing `textwrap` path unchanged (word boundaries, optional indentation, hyphenation). - **URL-only lines** (with at most decorative markers like `│`, `-`, `1.`) are emitted unwrapped so terminal link detection works; ratatui's `Wrap { trim: false }` handles the final character wrap at render time. - **Mixed lines** (URL + substantive non-URL prose) flow through `adaptive_wrap_line` so prose wraps naturally at word boundaries while URL tokens remain unsplit. Height measurement everywhere now delegates to `Paragraph::line_count(width)`, which accounts for the visual row cost of overflowed lines. This single source of truth replaces ad-hoc line counting in individual cells. For terminal scrollback (the VT100 path that prints history when the TUI exits), URL-only lines are emitted unwrapped so the terminal's own link detector can find them. Mixed URL+prose lines use adaptive wrapping so surrounding text wraps naturally. Continuation rows are pre-cleared to avoid stale content artifacts. ## Non-goals - Full RFC 3986 URL parsing. The detector is a conservative heuristic that covers `scheme://host`, bare domains (`example.com/path`), `localhost:port`, and IPv4 hosts. IPv6 (`[::1]:8080`) and exotic schemes are intentionally excluded from v1. - Changing wrapping behavior for non-URL content. - Reflowing or reformatting existing terminal scrollback on resize. ## Tradeoffs | Decision | Upside | Downside | |----------|--------|----------| | Heuristic URL detection vs. full parser | Fast, zero-alloc on the hot path; conservative enough to reject file paths like `src/main.rs` | False negatives on obscure URL formats (they get split as before) | | Adaptive (three-path) wrapping | Non-URL lines are untouched — no behavior change, no perf cost; mixed lines wrap prose naturally while preserving URLs | Three wrapping strategies to reason about when debugging layout | | Row-based truncation with line-unit ellipsis | Accurate viewport budget; stable "N lines omitted" count across terminal widths | `truncate_lines_middle` is more complex (must compute per-line row cost) | | Unwrapped URL-only lines in scrollback | Terminal emulators detect clickable links; copy-paste gets the full URL | TUI and scrollback formatting diverge for URL-only lines | | Default `desired_height` via `Paragraph::line_count` | DRY — most cells inherit correct measurement | Cells with custom layout must remember to override | ## Architecture ```mermaid flowchart TD A["adaptive_wrap_*()"] --> B{"line_contains_url_like?"} B -- No URL tokens --> C["word_wrap_line
(textwrap default)"] B -- Has URL tokens --> D{"mixed URL + prose?"} D -- "URL-only
(+ decorative markers)" --> E["emit unwrapped
(terminal char-wraps)"] D -- "Mixed
(URL + substantive text)" --> F["adaptive_wrap_line
(AsciiSpace + custom WordSplitter)"] C --> G["Paragraph::line_count(w)
(single height truth)"] E --> G F --> G ``` **Changed files:** | File | Role | |------|------| | `wrapping.rs` | URL detection heuristics, mixed-line detection, `adaptive_wrap_*` functions, custom `WordSplitter` | | `exec_cell/render.rs` | Row-aware `truncate_lines_middle`, adaptive wrapping for command/output display | | `history_cell.rs` | Migrate all cell types to `adaptive_wrap_*`; default `desired_height` via `Paragraph::line_count` | | `insert_history.rs` | Three-path scrollback wrapping (unwrapped URL-only, adaptive mixed, word-wrapped text); continuation row clearing | | `app_link_view.rs` | Adaptive wrapping for setup URL; `desired_height` via `Paragraph::line_count` | | `markdown_render.rs` | Adaptive wrapping in `finish_paragraph` | | `model_migration.rs` | Viewport-aware wrapping for narrow-pane markdown | | `pager_overlay.rs` | `Wrap { trim: false }` for transcript and streaming chunks | | `queued_user_messages.rs` | Migrate to `adaptive_wrap_lines` | | `status/card.rs` | Migrate to `adaptive_wrap_lines` | ## Observability - **Ellipsis message** in truncated exec output reports omitted count in logical lines (stable across resize) rather than viewport rows (fluctuates). - URL detection is deterministic and stateless — no hidden caching or memoization to go stale. - Height mismatch bugs surface immediately as visual clipping or gaps; the `Paragraph::line_count` path is the same code ratatui uses at render time, so measurement and rendering cannot diverge. ## Tests 26 new unit tests across 7 files, covering: - **URL integrity**: assert a URL-like token appears on exactly one rendered line (not split across two). - **Height accuracy**: compare `desired_height()` against `Paragraph::line_count()` for URL-containing content. - **Row-aware truncation**: verify ellipsis counts logical lines and output fits within the row budget. - **Scrollback rendering**: VT100 backend tests confirm prefix and URL land on the same row; continuation rows are cleared; mixed URL+prose lines wrap prose while preserving URL tokens. - **Mixed URL+prose detection**: `line_has_mixed_url_and_non_url_tokens` correctly distinguishes lines with substantive non-URL text from lines with only decorative markers alongside a URL. - **Heuristic correctness**: positive matches (`https://...`, `example.com/path`, `localhost:3000/api`, `192.168.1.1:8080/health`) and negative matches (`src/main.rs`, `foo/bar`, `hello-world`). ## Risks and open items 1. **URL-like tokens in code output** (e.g. `example.com/api` inside a JSON blob) will trigger URL-preserving wrap on that line. This is acceptable — the worst case is a slightly wider line, not broken output. 2. **Very long non-URL tokens on a URL line** can only break at character boundaries (the custom splitter emits all char indices for non-URL words). On extremely narrow terminals this could overflow, but narrow terminals already degrade gracefully. 3. **No IPv6 support** — `[::1]:8080/path` will be treated as a non-URL and may get split. Can be added later without API changes. Fixes #5457 --- codex-rs/tui/src/bottom_pane/app_link_view.rs | 108 +++- .../src/bottom_pane/queued_user_messages.rs | 36 +- codex-rs/tui/src/custom_terminal.rs | 44 +- codex-rs/tui/src/exec_cell/render.rs | 385 +++++++++-- codex-rs/tui/src/history_cell.rs | 394 ++++++++--- codex-rs/tui/src/insert_history.rs | 213 +++++- codex-rs/tui/src/markdown_render.rs | 18 +- codex-rs/tui/src/model_migration.rs | 37 +- codex-rs/tui/src/onboarding/auth.rs | 142 +++- .../onboarding/auth/headless_chatgpt_login.rs | 14 +- codex-rs/tui/src/pager_overlay.rs | 7 +- ...pdate_with_note_and_wrapping_snapshot.snap | 4 +- codex-rs/tui/src/status/card.rs | 4 +- codex-rs/tui/src/wrapping.rs | 610 +++++++++++++++++- 14 files changed, 1839 insertions(+), 177 deletions(-) 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:?}"); + } }