mirror of
https://github.com/openai/codex.git
synced 2026-04-29 17:06:51 +00:00
## 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 | |---|---| | <img width="777" height="1002" alt="Screenshot 2026-02-17 at 7 59 11 PM" src="https://github.com/user-attachments/assets/193a89a0-7e56-49c5-8b76-53499a76e7e3" /> | <img width="777" height="1002" alt="Screenshot 2026-02-17 at 7 58 40 PM" src="https://github.com/user-attachments/assets/0b9b4c14-aafb-439f-9ffe-f6bba556f95e" /> | ## 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<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 ``` **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
206 lines
7.0 KiB
Rust
206 lines
7.0 KiB
Rust
use crossterm::event::KeyCode;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::widgets::Paragraph;
|
|
|
|
use crate::key_hint;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::adaptive_wrap_lines;
|
|
|
|
/// Widget that displays a list of user messages queued while a turn is in progress.
|
|
///
|
|
/// The widget shows a key hint at the bottom (e.g. "⌥ + ↑ edit") telling the
|
|
/// user how to pop the most recent queued message back into the composer.
|
|
/// Because some terminals intercept certain modifier-key combinations, the
|
|
/// displayed binding is configurable via [`set_edit_binding`](Self::set_edit_binding).
|
|
pub(crate) struct QueuedUserMessages {
|
|
pub messages: Vec<String>,
|
|
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
|
/// be overridden for terminals where that chord is unavailable.
|
|
edit_binding: key_hint::KeyBinding,
|
|
}
|
|
|
|
impl QueuedUserMessages {
|
|
pub(crate) fn new() -> Self {
|
|
Self {
|
|
messages: Vec::new(),
|
|
edit_binding: key_hint::alt(KeyCode::Up),
|
|
}
|
|
}
|
|
|
|
/// Replace the keybinding shown in the hint line at the bottom of the
|
|
/// queued-messages list. The caller is responsible for also wiring the
|
|
/// corresponding key event handler.
|
|
pub(crate) fn set_edit_binding(&mut self, binding: key_hint::KeyBinding) {
|
|
self.edit_binding = binding;
|
|
}
|
|
|
|
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
|
|
if self.messages.is_empty() || width < 4 {
|
|
return Box::new(());
|
|
}
|
|
|
|
let mut lines = vec![];
|
|
|
|
for message in &self.messages {
|
|
let wrapped = adaptive_wrap_lines(
|
|
message.lines().map(|line| line.dim().italic()),
|
|
RtOptions::new(width as usize)
|
|
.initial_indent(Line::from(" ↳ ".dim()))
|
|
.subsequent_indent(Line::from(" ")),
|
|
);
|
|
let len = wrapped.len();
|
|
for line in wrapped.into_iter().take(3) {
|
|
lines.push(line);
|
|
}
|
|
if len > 3 {
|
|
lines.push(Line::from(" …".dim().italic()));
|
|
}
|
|
}
|
|
|
|
lines.push(
|
|
Line::from(vec![
|
|
" ".into(),
|
|
self.edit_binding.into(),
|
|
" edit".into(),
|
|
])
|
|
.dim(),
|
|
);
|
|
|
|
Paragraph::new(lines).into()
|
|
}
|
|
}
|
|
|
|
impl Renderable for QueuedUserMessages {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
if area.is_empty() {
|
|
return;
|
|
}
|
|
|
|
self.as_renderable(area.width).render(area, buf);
|
|
}
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
self.as_renderable(width).desired_height(width)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use insta::assert_snapshot;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn desired_height_empty() {
|
|
let queue = QueuedUserMessages::new();
|
|
assert_eq!(queue.desired_height(40), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn desired_height_one_message() {
|
|
let mut queue = QueuedUserMessages::new();
|
|
queue.messages.push("Hello, world!".to_string());
|
|
assert_eq!(queue.desired_height(40), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn render_one_message() {
|
|
let mut queue = QueuedUserMessages::new();
|
|
queue.messages.push("Hello, world!".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_one_message", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_two_messages() {
|
|
let mut queue = QueuedUserMessages::new();
|
|
queue.messages.push("Hello, world!".to_string());
|
|
queue.messages.push("This is another message".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_two_messages", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_more_than_three_messages() {
|
|
let mut queue = QueuedUserMessages::new();
|
|
queue.messages.push("Hello, world!".to_string());
|
|
queue.messages.push("This is another message".to_string());
|
|
queue.messages.push("This is a third message".to_string());
|
|
queue.messages.push("This is a fourth message".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_more_than_three_messages", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_wrapped_message() {
|
|
let mut queue = QueuedUserMessages::new();
|
|
queue
|
|
.messages
|
|
.push("This is a longer message that should be wrapped".to_string());
|
|
queue.messages.push("This is another message".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
|
assert_snapshot!("render_wrapped_message", format!("{buf:?}"));
|
|
}
|
|
|
|
#[test]
|
|
fn render_many_line_message() {
|
|
let mut queue = QueuedUserMessages::new();
|
|
queue
|
|
.messages
|
|
.push("This is\na message\nwith many\nlines".to_string());
|
|
let width = 40;
|
|
let height = queue.desired_height(width);
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
|
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::<String>()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
assert!(
|
|
!rendered_rows.iter().any(|row| row.contains('…')),
|
|
"expected no wrapped-ellipsis row for URL-like token, got rows: {rendered_rows:?}"
|
|
);
|
|
}
|
|
}
|