Skip to content

fix(tui): preserve URL clickability across all TUI views#12067

Merged
etraut-openai merged 21 commits intoopenai:mainfrom
fcoury:fix/url-handling
Feb 21, 2026
Merged

fix(tui): preserve URL clickability across all TUI views#12067
etraut-openai merged 21 commits intoopenai:mainfrom
fcoury:fix/url-handling

Conversation

@fcoury
Copy link
Copy Markdown
Contributor

@fcoury fcoury commented Feb 18, 2026

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

flowchart TD
    A["adaptive_wrap_*()"] --> B{"line_contains_url_like?"}
    B -- No URL tokens --> C["word_wrap_line<br/>(textwrap default)"]
    B -- Has URL tokens --> D{"mixed URL + prose?"}
    D -- "URL-only<br/>(+ decorative markers)" --> E["emit unwrapped<br/>(terminal char-wraps)"]
    D -- "Mixed<br/>(URL + substantive text)" --> F["adaptive_wrap_line<br/>(AsciiSpace + custom WordSplitter)"]
    C --> G["Paragraph::line_count(w)<br/>(single height truth)"]
    E --> G
    F --> G
Loading

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

Copilot AI review requested due to automatic review settings February 18, 2026 00:58
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request fixes a critical usability issue where URLs containing / and - characters are split across terminal lines by textwrap's default hyphenation rules, breaking terminal link detection and making URLs unclickable. The fix introduces URL-aware wrapping that treats URL-like tokens as atomic units while maintaining backward compatibility for non-URL content.

Changes:

  • Adds URL detection heuristics and adaptive wrapping functions to the wrapping module that preserve URL integrity
  • Migrates all user-facing content rendering (history cells, exec output, markdown, etc.) from word_wrap_* to adaptive_wrap_* functions
  • Updates height calculation throughout to use Paragraph::line_count for accurate viewport row measurement when lines contain long URLs
  • Implements two-path terminal scrollback rendering (unwrapped URLs for terminal detection, word-wrapped text) with continuation row clearing
  • Adds row-aware truncation in exec cells that accounts for the viewport cost of wrapped lines

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.

Show a summary per file
File Description
wrapping.rs Core implementation: URL detection heuristics, adaptive wrapping functions, custom WordSplitter, and comprehensive test suite (10 new tests)
history_cell.rs Migrates all cell types to adaptive_wrap_*; establishes default desired_height using Paragraph::line_count; removes redundant custom height implementations; adds 5 new tests
exec_cell/render.rs Implements row-aware truncate_lines_middle that uses Paragraph::line_count to properly budget viewport rows; migrates to adaptive_wrap_* for commands and output; adds 5 new tests
insert_history.rs Two-path scrollback: URL lines kept intact for terminal detection, non-URL lines word-wrapped; pre-clears continuation rows to prevent stale content artifacts; adds 4 new tests
markdown_render.rs Migrates finish_paragraph to adaptive_wrap_line; adds 1 new test
model_migration.rs Adds Wrap { trim: false } to markdown rendering; adds 1 new test
pager_overlay.rs Adds Wrap { trim: false } to transcript and streaming chunk rendering
queued_user_messages.rs Migrates to adaptive_wrap_lines; adds 1 new test
app_link_view.rs Migrates setup URL rendering to adaptive_wrap_lines; updates desired_height to use Paragraph::line_count; adds 2 new tests
status/card.rs Migrates rate limit note rendering to adaptive_wrap_lines
codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap Expected snapshot update reflecting improved wrapping behavior

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@etraut-openai
Copy link
Copy Markdown
Collaborator

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown
Contributor

Security review completed. No security issues were found in this pull request.

View security finding report

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 24ab72a1b6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Prevent mid-token URL splitting in exec command rendering by using URL-aware wrap options (AsciiSpace, NoHyphenation, break_words(false)) for command header/continuation lines.

Keep wrapped behavior unchanged for non-URL lines.

Fix scrollback insertion regression where re-wrapping prefixed URL lines produced orphan `│` lines by preserving URL lines intact while still accounting for visual row usage.

Add regression coverage for: URL no-split behavior in exec_cell command display; URL intact behavior with AsciiSpace + NoHyphenation + break_words(false) in wrapping helpers; insert_history prefixed URL rendering so prefix and URL stay on the same row and no orphan prefix row appears.

Tests: cargo test -p codex-tui
Centralize URL-like token detection and adaptive wrapping in wrapping.rs, including non-http(s) URL-like forms (bare domains, localhost, IPv4 with path/query/fragment).

Apply adaptive wrapping across exec rendering, history cells, markdown wrapping, app link setup view, and insert_history scrollback handling to avoid splitting link tokens or creating orphan prefix lines.

Add regression tests for URL-like detection and rendering paths (exec output, history cells, markdown, app-link setup, and insert_history).

Validation: just fmt and cargo test -p codex-tui.
Document the intent and contracts behind the adaptive wrapping
pipeline, the HistoryCell trait's Paragraph-based height defaults,
the row-aware truncation in ExecCell, and the URL-line scrollback
strategy in insert_history.
Switch the last two call sites from word_wrap_lines to
adaptive_wrap_lines so URL-like tokens in queued user messages and
the status card are never split across lines.

Adds a test confirming a long URL-like message does not produce
extraneous wrapped-ellipsis rows.
…unts URL rows

The upstream remote_image_urls change added a desired_height override
that counts logical lines instead of viewport rows. This undercounts
when a URL overflows its column width and wraps visually. Remove the
override so the trait default (Paragraph::line_count) is used.
Lines containing both a URL and substantive non-URL text now flow
through adaptive_wrap_line so prose wraps naturally while URL tokens
remain unsplit.  Pure-URL lines (with only decorative markers like
│ or list prefixes) still skip wrapping to preserve terminal link
detection.
Long OAuth URLs wrap across multiple terminal rows, and ratatui's
cell-based rendering emits MoveTo at each row boundary, breaking
normal terminal link detection.  OSC 8 hyperlinks explicitly mark
text as clickable regardless of row wrapping.

After the Paragraph renders, scan the buffer for cyan+underlined
cells (the URL's distinctive style) and wrap each cell's symbol
with `\x1B]8;;URL\x07char\x1B]8;;\x07`.  Add display_width() to
custom_terminal.rs that strips OSC escape sequences before width
calculation so the layout engine isn't confused by the escape
payload characters.
Adjust `ExecCell::truncate_lines_middle` in
`codex-rs/tui/src/exec_cell/render.rs` to avoid relying on
`Paragraph::line_count` for whitespace-only lines.

Whitespace-only prefixed output lines (for example `"    "`) could be
counted as two rows with `Wrap { trim: false }`, which caused early
ellipsis insertion and inflated omitted-line counts in exec output.
Add a regression test that reproduces the blank-line case and asserts
no truncation when content fits the row budget.
@etraut-openai
Copy link
Copy Markdown
Collaborator

@codex review

@etraut-openai etraut-openai merged commit 2ba2c57 into openai:main Feb 21, 2026
49 of 55 checks passed
@github-actions github-actions bot locked and limited conversation to collaborators Feb 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Long URLs/links break when wrapping

3 participants