diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 075b5a7ad..423217a6f 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -29,6 +29,7 @@ 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, @@ -154,7 +155,7 @@ pub(crate) fn output_lines( }; if show_ellipsis { let omitted = total - 2 * line_limit; - out.push(format!("… +{omitted} lines").into()); + out.push(ExecCell::output_ellipsis_line(omitted)); } let tail_start = if show_ellipsis { @@ -250,6 +251,14 @@ impl HistoryCell for ExecCell { } 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> { let mut out: Vec> = Vec::new(); out.push(Line::from(vec![ @@ -559,25 +568,24 @@ impl ExecCell { 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 + // 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 omitted = base + extra; - return vec![Self::ellipsis_line_with_prefix( - omitted, + 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 head_budget = (max_rows - 1) / 2; - let tail_budget = max_rows - head_budget - 1; + 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> = Vec::new(); let mut head_rows = 0usize; let mut head_end = 0usize; @@ -611,7 +619,7 @@ impl ExecCell { .len() .saturating_sub(out.len() + tail_lines_reversed.len()) .saturating_sub(usize::from(omitted_hint.is_some())); - out.push(Self::ellipsis_line_with_prefix( + out.push(Self::output_ellipsis_line_with_prefix( base + additional, ellipsis_prefix.as_ref(), )); @@ -625,11 +633,27 @@ impl ExecCell { 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> { + 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(format!("… +{omitted} lines").dim()); + line.push_span(Self::output_ellipsis_text(omitted).dim()); line } } @@ -692,6 +716,13 @@ mod tests { 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::() + } + #[test] fn user_shell_output_is_limited_by_screen_lines() { let long_url_like = format!( @@ -785,6 +816,16 @@ mod tests { 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] @@ -792,33 +833,82 @@ mod tests { let lines = vec![ Line::from(" └ short"), Line::from(" this-is-a-very-long-token-that-wraps-many-rows"), - Line::from(" … +4 lines"), + Line::from(format!( + " {}", + ExecCell::output_ellipsis_text(/*omitted*/ 4) + )), Line::from(" tail"), ]; let truncated = ExecCell::truncate_lines_middle( &lines, /*max_rows*/ 2, - /*width*/ 12, + /*width*/ 80, Some(4), Some(Line::from(" ".dim())), ); - let rendered: Vec = truncated - .iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect(); + let rendered: Vec = truncated.iter().map(render_line_text).collect(); assert!( - rendered.iter().any(|line| line.contains("… +6 lines")), + 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 = 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 = 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")]; diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap index ae7ee1518..5ac16217c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap @@ -5,6 +5,6 @@ expression: rendered • Ran seq 1 10 1>&2 && false └ 1 2 - … +6 lines + … +6 lines (ctrl + t to view transcript) 9 10