Files
codex/codex-rs/tui/src/exec_cell/render.rs
T
Felipe Coury 2ba2c57af4 fix(tui): preserve URL clickability across all TUI views (#12067)
## 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
2026-02-21 15:31:41 -08:00

969 lines
34 KiB
Rust

use std::time::Instant;
use super::model::CommandOutput;
use super::model::ExecCall;
use super::model::ExecCell;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryCell;
use crate::render::highlight::highlight_bash_to_lines;
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::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;
use codex_shell_command::bash::extract_bash_command;
use codex_utils_elapsed::format_duration;
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;
pub(crate) const TOOL_CALL_MAX_LINES: usize = 5;
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
const MAX_INTERACTION_PREVIEW_CHARS: usize = 80;
pub(crate) struct OutputLinesParams {
pub(crate) line_limit: usize,
pub(crate) only_err: bool,
pub(crate) include_angle_pipe: bool,
pub(crate) include_prefix: bool,
}
pub(crate) fn new_active_exec_command(
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
source: ExecCommandSource,
interaction_input: Option<String>,
animations_enabled: bool,
) -> ExecCell {
ExecCell::new(
ExecCall {
call_id,
command,
parsed,
output: None,
source,
start_time: Some(Instant::now()),
duration: None,
interaction_input,
},
animations_enabled,
)
}
fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String {
let command_display = if let Some((_, script)) = extract_bash_command(command) {
script.to_string()
} else {
command.join(" ")
};
match input {
Some(data) if !data.is_empty() => {
let preview = summarize_interaction_input(data);
format!("Interacted with `{command_display}`, sent `{preview}`")
}
_ => format!("Waited for `{command_display}`"),
}
}
fn summarize_interaction_input(input: &str) -> String {
let single_line = input.replace('\n', "\\n");
let sanitized = single_line.replace('`', "\\`");
if sanitized.chars().count() <= MAX_INTERACTION_PREVIEW_CHARS {
return sanitized;
}
let mut preview = String::new();
for ch in sanitized.chars().take(MAX_INTERACTION_PREVIEW_CHARS) {
preview.push(ch);
}
preview.push_str("...");
preview
}
#[derive(Clone)]
pub(crate) struct OutputLines {
pub(crate) lines: Vec<Line<'static>>,
pub(crate) omitted: Option<usize>,
}
pub(crate) fn output_lines(
output: Option<&CommandOutput>,
params: OutputLinesParams,
) -> OutputLines {
let OutputLinesParams {
line_limit,
only_err,
include_angle_pipe,
include_prefix,
} = params;
let CommandOutput {
aggregated_output, ..
} = match output {
Some(output) if only_err && output.exit_code == 0 => {
return OutputLines {
lines: Vec::new(),
omitted: None,
};
}
Some(output) => output,
None => {
return OutputLines {
lines: Vec::new(),
omitted: None,
};
}
};
let src = aggregated_output;
let lines: Vec<&str> = src.lines().collect();
let total = lines.len();
let mut out: Vec<Line<'static>> = Vec::new();
let head_end = total.min(line_limit);
for (i, raw) in lines[..head_end].iter().enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if !include_prefix {
""
} else if i == 0 && include_angle_pipe {
" └ "
} else {
" "
};
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
let show_ellipsis = total > 2 * line_limit;
let omitted = if show_ellipsis {
Some(total - 2 * line_limit)
} else {
None
};
if show_ellipsis {
let omitted = total - 2 * line_limit;
out.push(format!("… +{omitted} lines").into());
}
let tail_start = if show_ellipsis {
total - line_limit
} else {
head_end
};
for raw in lines[tail_start..].iter() {
let mut line = ansi_escape_line(raw);
if include_prefix {
line.spans.insert(0, " ".into());
}
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
OutputLines {
lines: out,
omitted,
}
}
pub(crate) fn spinner(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
if !animations_enabled {
return "•".dim();
}
let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default();
if supports_color::on_cached(supports_color::Stream::Stdout)
.map(|level| level.has_16m)
.unwrap_or(false)
{
shimmer_spans("•")[0].clone()
} else {
let blink_on = (elapsed.as_millis() / 600).is_multiple_of(2);
if blink_on { "•".into() } else { "◦".dim() }
}
}
impl HistoryCell for ExecCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.is_exploring_cell() {
self.exploring_display_lines(width)
} else {
self.command_display_lines(width)
}
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![];
for (i, call) in self.iter_calls().enumerate() {
if i > 0 {
lines.push("".into());
}
let script = strip_bash_lc_and_escape(&call.command);
let highlighted_script = highlight_bash_to_lines(&script);
let cmd_display = adaptive_wrap_lines(
&highlighted_script,
RtOptions::new(width as usize)
.initial_indent("$ ".magenta().into())
.subsequent_indent(" ".into()),
);
lines.extend(cmd_display);
if let Some(output) = call.output.as_ref() {
if !call.is_unified_exec_interaction() {
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 = adaptive_wrap_line(&unwrapped, wrap_opts.clone());
push_owned_lines(&wrapped, &mut lines);
}
}
let duration = call
.duration
.map(format_duration)
.unwrap_or_else(|| "unknown".to_string());
let mut result: Line = if output.exit_code == 0 {
Line::from("✓".green().bold())
} else {
Line::from(vec![
"✗".red().bold(),
format!(" ({})", output.exit_code).into(),
])
};
result.push_span(format!(" • {duration}").dim());
lines.push(result);
}
}
lines
}
}
impl ExecCell {
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from(vec![
if self.is_active() {
spinner(self.active_start_time(), self.animations_enabled())
} else {
"•".dim()
},
" ".into(),
if self.is_active() {
"Exploring".bold()
} else {
"Explored".bold()
},
]));
let mut calls = self.calls.clone();
let mut out_indented = Vec::new();
while !calls.is_empty() {
let mut call = calls.remove(0);
if call
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }))
{
while let Some(next) = calls.first() {
if next
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }))
{
call.parsed.extend(next.parsed.clone());
calls.remove(0);
} else {
break;
}
}
}
let reads_only = call
.parsed
.iter()
.all(|parsed| matches!(parsed, ParsedCommand::Read { .. }));
let call_lines: Vec<(&str, Vec<Span<'static>>)> = if reads_only {
let names = call
.parsed
.iter()
.map(|parsed| match parsed {
ParsedCommand::Read { name, .. } => name.clone(),
_ => unreachable!(),
})
.unique();
vec![(
"Read",
Itertools::intersperse(names.into_iter().map(Into::into), ", ".dim()).collect(),
)]
} else {
let mut lines = Vec::new();
for parsed in &call.parsed {
match parsed {
ParsedCommand::Read { name, .. } => {
lines.push(("Read", vec![name.clone().into()]));
}
ParsedCommand::ListFiles { cmd, path } => {
lines.push(("List", vec![path.clone().unwrap_or(cmd.clone()).into()]));
}
ParsedCommand::Search { cmd, query, path } => {
let spans = match (query, path) {
(Some(q), Some(p)) => {
vec![q.clone().into(), " in ".dim(), p.clone().into()]
}
(Some(q), None) => vec![q.clone().into()],
_ => vec![cmd.clone().into()],
};
lines.push(("Search", spans));
}
ParsedCommand::Unknown { cmd } => {
lines.push(("Run", vec![cmd.clone().into()]));
}
}
}
lines
};
for (title, line) in call_lines {
let line = Line::from(line);
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
let subsequent_indent = " ".repeat(initial_indent.width()).into();
let wrapped = adaptive_wrap_line(
&line,
RtOptions::new(width as usize)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent),
);
push_owned_lines(&wrapped, &mut out_indented);
}
}
out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into()));
out
}
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let [call] = &self.calls.as_slice() else {
panic!("Expected exactly one call in a command display cell");
};
let layout = EXEC_DISPLAY_LAYOUT;
let success = call.output.as_ref().map(|o| o.exit_code == 0);
let bullet = match success {
Some(true) => "•".green().bold(),
Some(false) => "•".red().bold(),
None => spinner(call.start_time, self.animations_enabled()),
};
let is_interaction = call.is_unified_exec_interaction();
let title = if is_interaction {
""
} else if self.is_active() {
"Running"
} else if call.is_user_shell_command() {
"You ran"
} else {
"Ran"
};
let mut header_line = if is_interaction {
Line::from(vec![bullet.clone(), " ".into()])
} else {
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()])
};
let header_prefix_width = header_line.width();
let cmd_display = if call.is_unified_exec_interaction() {
format_unified_exec_interaction(&call.command, call.interaction_input.as_deref())
} else {
strip_bash_lc_and_escape(&call.command)
};
let highlighted_lines = highlight_bash_to_lines(&cmd_display);
let continuation_wrap_width = layout.command_continuation.wrap_width(width);
let continuation_opts =
RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation);
let mut continuation_lines: Vec<Line<'static>> = Vec::new();
if let Some((first, rest)) = highlighted_lines.split_first() {
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<Line<'static>> = Vec::new();
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);
}
continuation_lines.extend(first_wrapped_iter);
for line in rest {
push_owned_lines(
&adaptive_wrap_line(line, continuation_opts.clone()),
&mut continuation_lines,
);
}
}
let mut lines: Vec<Line<'static>> = vec![header_line];
let continuation_lines = Self::limit_lines_from_start(
&continuation_lines,
layout.command_continuation_max_lines,
);
if !continuation_lines.is_empty() {
lines.extend(prefix_lines(
continuation_lines,
Span::from(layout.command_continuation.initial_prefix).dim(),
Span::from(layout.command_continuation.subsequent_prefix).dim(),
));
}
if let Some(output) = call.output.as_ref() {
let line_limit = if call.is_user_shell_command() {
USER_SHELL_TOOL_CALL_MAX_LINES
} else {
TOOL_CALL_MAX_LINES
};
let raw_output = output_lines(
Some(output),
OutputLinesParams {
line_limit,
only_err: false,
include_angle_pipe: false,
include_prefix: false,
},
);
let display_limit = if call.is_user_shell_command() {
USER_SHELL_TOOL_CALL_MAX_LINES
} else {
layout.output_max_lines
};
if raw_output.lines.is_empty() {
if !call.is_unified_exec_interaction() {
lines.extend(prefix_lines(
vec![Line::from("(no output)".dim())],
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
}
} else {
// Wrap first so that truncation is applied to on-screen lines
// rather than logical lines. This ensures that a small number
// of very long lines cannot flood the viewport.
let mut wrapped_output: Vec<Line<'static>> = Vec::new();
let output_wrap_width = layout.output_block.wrap_width(width);
let output_opts =
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
for line in &raw_output.lines {
push_owned_lines(
&adaptive_wrap_line(line, output_opts.clone()),
&mut wrapped_output,
);
}
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(trimmed_output);
}
}
}
lines
}
fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec<Line<'static>> {
if lines.len() <= keep {
return lines.to_vec();
}
if keep == 0 {
return vec![Self::ellipsis_line(lines.len())];
}
let mut out: Vec<Line<'static>> = lines[..keep].to_vec();
out.push(Self::ellipsis_line(lines.len() - keep));
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_rows: usize,
width: u16,
omitted_hint: Option<usize>,
ellipsis_prefix: Option<Line<'static>>,
) -> Vec<Line<'static>> {
let width = width.max(1);
if max_rows == 0 {
return Vec::new();
}
let line_rows: Vec<usize> = 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_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);
// When an existing ellipsis is present, `lines` already includes
// that single representation line; exclude it from the count of
// additionally omitted content lines.
let extra = lines
.len()
.saturating_sub(usize::from(omitted_hint.is_some()));
let omitted = base + extra;
return vec![Self::ellipsis_line_with_prefix(
omitted,
ellipsis_prefix.as_ref(),
)];
}
let head_budget = (max_rows - 1) / 2;
let tail_budget = max_rows - head_budget - 1;
let mut head_lines: Vec<Line<'static>> = 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<Line<'static>> = 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(out.len() + tail_lines_reversed.len())
.saturating_sub(usize::from(omitted_hint.is_some()));
out.push(Self::ellipsis_line_with_prefix(
base + additional,
ellipsis_prefix.as_ref(),
));
out.extend(tail_lines_reversed.into_iter().rev());
out
}
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)]
struct PrefixedBlock {
initial_prefix: &'static str,
subsequent_prefix: &'static str,
}
impl PrefixedBlock {
const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self {
Self {
initial_prefix,
subsequent_prefix,
}
}
fn wrap_width(self, total_width: u16) -> usize {
let prefix_width = UnicodeWidthStr::width(self.initial_prefix)
.max(UnicodeWidthStr::width(self.subsequent_prefix));
usize::from(total_width).saturating_sub(prefix_width).max(1)
}
}
#[derive(Clone, Copy)]
struct ExecDisplayLayout {
command_continuation: PrefixedBlock,
command_continuation_max_lines: usize,
output_block: PrefixedBlock,
output_max_lines: usize,
}
impl ExecDisplayLayout {
const fn new(
command_continuation: PrefixedBlock,
command_continuation_max_lines: usize,
output_block: PrefixedBlock,
output_max_lines: usize,
) -> Self {
Self {
command_continuation,
command_continuation_max_lines,
output_block,
output_max_lines,
}
}
}
const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new(
PrefixedBlock::new(" │ ", " │ "),
2,
PrefixedBlock::new(" └ ", " "),
5,
);
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::ExecCommandSource;
use pretty_assertions::assert_eq;
#[test]
fn user_shell_output_is_limited_by_screen_lines() {
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?
let output = CommandOutput {
exit_code: 0,
aggregated_output,
formatted_output: String::new(),
};
let width = 20;
let layout = EXEC_DISPLAY_LAYOUT;
let raw_output = output_lines(
Some(&output),
OutputLinesParams {
// Large enough to include all logical lines without
// triggering the ellipsis in `output_lines`.
line_limit: 100,
only_err: false,
include_angle_pipe: false,
include_prefix: false,
},
);
let output_wrap_width = layout.output_block.wrap_width(width);
let output_opts =
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
let mut full_wrapped_output: Vec<Line<'static>> = Vec::new();
for line in &raw_output.lines {
push_owned_lines(
&adaptive_wrap_line(line, output_opts.clone()),
&mut full_wrapped_output,
);
}
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
// this ever fails, the test no longer exercises the regression.
assert!(
full_screen_lines > USER_SHELL_TOOL_CALL_MAX_LINES,
"expected unbounded wrapping to produce more than {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines, got {full_screen_lines}",
);
let call = ExecCall {
call_id: "call-id".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo long".into()],
parsed: Vec::new(),
output: Some(output),
source: ExecCommandSource::UserShell,
start_time: None,
duration: None,
interaction_input: None,
};
let cell = ExecCell::new(call, false);
// 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);
let contains_ellipsis = lines
.iter()
.any(|line| line.spans.iter().any(|span| span.content.contains("… +")));
// Regression guard: previously this scenario could render hundreds of
// wrapped rows because truncation happened before final viewport
// wrapping. The row-aware truncation now caps visible output rows.
assert!(
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<String> = truncated
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.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<String> = cell
.command_display_lines(36)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.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<String> = cell
.display_lines(36)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.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<String> = cell
.command_display_lines(36)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.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}"
);
}
}