Files
codex/codex-rs/tui/src/exec_cell/render.rs
T
mom-oai 25a0f6784d [codex] Show ctrl + t hint on truncated exec output in TUI (#17076)
## What

Show an inline `ctrl + t to view transcript` hint when exec output is
truncated in the main TUI chat view.

## Why

Today, truncated exec output shows `… +N lines`, but it does not tell
users that the full content is already available through the existing
transcript overlay. That makes hidden output feel lost instead of
discoverable.

This change closes that discoverability gap without introducing a new
interaction model.

Fixes: CLI-5740

## How

- added an output-specific truncation hint in `ExecCell` rendering
- applied that hint in both exec-output truncation paths:
  - logical head/tail truncation before wrapping
  - row-budget truncation after wrapping
- preserved the existing row-budget behavior on narrow terminals by
reserving space for the longer hint line
- updated the relevant snapshot and added targeted regression coverage

## Intentional design decisions

- **Aligned shortcut styling with the visible footer UI**  
The inline hint uses `ctrl + t`, not `Ctrl+T`, to match the TUI’s
rendered key-hint style.

- **Kept the noun `transcript`**  
The product already exposes this flow as the transcript overlay, so the
hint points at the existing concept instead of inventing a new label.

- **Preserved narrow-terminal behavior**  
The longer hint text is accounted for in the row-budget truncation path
so the visible output still respects the existing viewport cap.

- **Did not add the hint to long command truncation**  
This PR only changes hidden **output** truncation. Long command
truncation still uses the plain ellipsis form because `ctrl + t` is not
the same kind of “show hidden output” escape hatch there.

- **Did not widen scope to other truncation surfaces**  
This does not change MCP/tool-call truncation in `history_cell.rs`, and
it does not change transcript-overlay behavior itself.

## Validation

### Automated
- `just fmt`
- `cargo test -p codex-tui`

### Manual
- ran `just tui-with-exec-server`
- executed `!seq 1 200`
- confirmed the main view showed the new `ctrl + t to view transcript`
truncation hint
- pressed `ctrl + t` and confirmed the transcript overlay still exposed
the full output
- closed the overlay and returned to the main view

## Visual proof

Screenshot/video attached in the PR UI showing:
- the truncated exec output row with the new hint
- the transcript overlay after `ctrl + t`
2026-04-09 11:01:30 -07:00

1067 lines
37 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;
const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript";
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(ExecCell::output_ellipsis_line(omitted));
}
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 output_ellipsis_text(omitted: usize) -> String {
format!("… +{omitted} lines ({TRANSCRIPT_HINT})")
}
fn output_ellipsis_line(omitted: usize) -> Line<'static> {
Line::from(vec![Self::output_ellipsis_text(omitted).dim()])
}
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();
}
// Reserve space for the transcript hint itself so the returned output
// still respects the row budget on narrow terminals.
let estimated_omitted = omitted_hint.unwrap_or(0)
+ lines
.len()
.saturating_sub(usize::from(omitted_hint.is_some()));
let ellipsis_rows =
Self::output_ellipsis_row_count(estimated_omitted, width, ellipsis_prefix.as_ref());
if ellipsis_rows >= max_rows {
return vec![Self::output_ellipsis_line_with_prefix(
estimated_omitted,
ellipsis_prefix.as_ref(),
)];
}
let available_rows = max_rows - ellipsis_rows;
let head_budget = available_rows / 2;
let tail_budget = available_rows - head_budget;
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::output_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()])
}
fn output_ellipsis_row_count(
omitted: usize,
width: u16,
prefix: Option<&Line<'static>>,
) -> usize {
Paragraph::new(Text::from(vec![Self::output_ellipsis_line_with_prefix(
omitted, prefix,
)]))
.wrap(Wrap { trim: false })
.line_count(width)
.max(1)
}
/// Builds an output ellipsis line (`… +N lines (ctrl + t to view transcript)`)
/// with an optional leading prefix so the ellipsis aligns with the output gutter.
fn output_ellipsis_line_with_prefix(
omitted: usize,
prefix: Option<&Line<'static>>,
) -> Line<'static> {
let mut line = prefix.cloned().unwrap_or_default();
line.push_span(Self::output_ellipsis_text(omitted).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("", ""),
/*command_continuation_max_lines*/ 2,
PrefixedBlock::new("", " "),
/*output_max_lines*/ 5,
);
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::protocol::ExecCommandSource;
use pretty_assertions::assert_eq;
fn render_line_text(line: &Line<'static>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
#[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, /*animations_enabled*/ 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"
);
let normalized = lines
.iter()
.map(render_line_text)
.join(" ")
.split_whitespace()
.join(" ");
assert!(
normalized.contains(TRANSCRIPT_HINT),
"expected truncated output to advertise transcript shortcut, got {normalized}"
);
}
#[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(format!(
" {}",
ExecCell::output_ellipsis_text(/*omitted*/ 4)
)),
Line::from(" tail"),
];
let truncated = ExecCell::truncate_lines_middle(
&lines,
/*max_rows*/ 2,
/*width*/ 80,
Some(4),
Some(Line::from(" ".dim())),
);
let rendered: Vec<String> = truncated.iter().map(render_line_text).collect();
assert!(
rendered
.iter()
.any(|line| line.contains("… +6 lines (ctrl + t to view transcript)")),
"expected omitted hint to count hidden lines (not wrapped rows), got: {rendered:?}"
);
}
#[test]
fn output_lines_ellipsis_includes_transcript_hint() {
let output = CommandOutput {
exit_code: 0,
aggregated_output: (1..=7).map(|n| n.to_string()).join("\n"),
formatted_output: String::new(),
};
let rendered: Vec<String> = output_lines(
Some(&output),
OutputLinesParams {
line_limit: 2,
only_err: false,
include_angle_pipe: false,
include_prefix: false,
},
)
.lines
.iter()
.map(render_line_text)
.collect();
assert!(
rendered
.iter()
.any(|line| line.contains("… +3 lines (ctrl + t to view transcript)")),
"expected logical truncation to include transcript hint, got: {rendered:?}"
);
}
#[test]
fn command_truncation_ellipsis_does_not_include_transcript_hint() {
let truncated = ExecCell::limit_lines_from_start(
&[
Line::from("first"),
Line::from("second"),
Line::from("third"),
],
/*keep*/ 2,
);
let rendered: Vec<String> = truncated.iter().map(render_line_text).collect();
assert_eq!(
rendered,
vec![
"first".to_string(),
"second".to_string(),
"… +1 lines".to_string(),
]
);
}
#[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, /*max_rows*/ 28, /*width*/ 80, /*omitted_hint*/ None,
/*ellipsis_prefix*/ 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, /*animations_enabled*/ false);
let rendered: Vec<String> = cell
.command_display_lines(/*width*/ 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, /*animations_enabled*/ false);
let rendered: Vec<String> = cell
.display_lines(/*width*/ 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, /*animations_enabled*/ false);
let rendered: Vec<String> = cell
.command_display_lines(/*width*/ 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, /*animations_enabled*/ 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}"
);
}
}