mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
[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`
This commit is contained in:
@@ -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<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = 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<Line<'static>> = 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::<String>()
|
||||
}
|
||||
|
||||
#[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<String> = truncated
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
let rendered: Vec<String> = 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<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")];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user