Unify TUI tool output previews with a three-row limit (#46492)

## What changed

- Use a shared preview renderer for agent command output and MCP results, showing the first three wrapped rows with a hidden-line count and `ctrl + t` transcript hint.
- Apply one preview budget across all MCP result blocks. Count partially displayed logical lines as hidden and hard-wrap long URLs to fit the available width.
- Preserve complete MCP result text in transcript and raw output, including trailing failure diagnostics.
- Bound preview input before wrapping to limit work on very long lines and combining characters.

## Testing

Add unit tests and snapshots for wrapped output, hidden-line counts, blank lines, combining characters, shared MCP block limits, transcript preservation, and matching streamed and completed command previews.

GitOrigin-RevId: bd3c2ec40ee837dc8a7a171aaf91577a0ac50985
This commit is contained in:
Eric Traut
2026-09-18 22:51:10 +00:00
committed by copyberry
parent 4d23af0975
commit 4f3867123f
18 changed files with 465 additions and 169 deletions
@@ -1361,7 +1361,7 @@ async fn failed_repl_mcp_tool_call_preserves_status_and_result() {
└ Script failed
{"exit_code": 0, "output": "ready", "chunk_id": "chunk-1"}
Script error:
permission denied
+1 line (ctrl + t to view transcript)
"#);
}
assert_eq!(
+38 -26
View File
@@ -13,6 +13,7 @@ use crate::motion::activity_indicator;
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::tool_output::tool_output_preview;
use crate::ui_consts::TRANSCRIPT_HINT;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
@@ -130,7 +131,7 @@ pub(crate) fn output_lines(
let head_end = total.min(line_limit).min(retained);
for (i, raw) in output.lines().take(head_end).enumerate() {
let mut line = ansi_escape_line(raw.as_ref());
let mut line = dimmed_output_line(raw.as_ref());
let prefix = if !include_prefix {
""
} else if i == 0 && include_angle_pipe {
@@ -138,10 +139,7 @@ pub(crate) fn output_lines(
} else {
" "
};
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
line.spans.insert(0, prefix.dim());
out.push(line);
}
@@ -157,13 +155,10 @@ pub(crate) fn output_lines(
let tail = output.lines().rev().take(tail_len).collect_vec();
for raw in tail.into_iter().rev() {
let mut line = ansi_escape_line(raw.as_ref());
let mut line = dimmed_output_line(raw.as_ref());
if include_prefix {
line.spans.insert(0, " ".into());
line.spans.insert(0, " ".dim());
}
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
@@ -173,6 +168,23 @@ pub(crate) fn output_lines(
}
}
fn dimmed_output_line(raw: &str) -> Line<'static> {
let mut line = ansi_escape_line(raw);
for span in &mut line.spans {
span.style = span.style.add_modifier(Modifier::DIM);
}
line
}
fn output_preview_lines(output: &CommandOutput, width: usize) -> Vec<Line<'static>> {
let (total, _) = output.line_counts();
tool_output_preview(
output.lines().map(|raw| dimmed_output_line(raw.as_ref())),
width,
total,
)
}
fn activity_marker(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
activity_indicator(
start_time,
@@ -419,25 +431,29 @@ impl ExecCell {
}
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
};
if !call.is_user_shell_command() {
let preview = output_preview_lines(output, layout.output_block.wrap_width(width));
let preview = if preview.is_empty() && !call.is_unified_exec_interaction() {
vec![Line::from("(no output)".dim())]
} else {
preview
};
lines.extend(prefix_lines(
preview,
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
return lines;
}
let raw_output = output_lines(
Some(output),
OutputLinesParams {
line_limit,
line_limit: USER_SHELL_TOOL_CALL_MAX_LINES,
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() {
@@ -469,7 +485,7 @@ impl ExecCell {
);
let trimmed_output = Self::truncate_lines_middle(
&prefixed_output,
display_limit,
USER_SHELL_TOOL_CALL_MAX_LINES,
width,
raw_output.omitted,
Some(Line::from(
@@ -663,7 +679,6 @@ struct ExecDisplayLayout {
command_continuation: PrefixedBlock,
command_continuation_max_lines: usize,
output_block: PrefixedBlock,
output_max_lines: usize,
}
impl ExecDisplayLayout {
@@ -671,13 +686,11 @@ impl ExecDisplayLayout {
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,
}
}
}
@@ -686,7 +699,6 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new(
PrefixedBlock::new(" │ ", " │ "),
/*command_continuation_max_lines*/ 2,
PrefixedBlock::new(" └ ", " "),
/*output_max_lines*/ 5,
);
#[cfg(test)]
+48 -58
View File
@@ -1,5 +1,5 @@
//! MCP tool-call, inventory, and output history cells.
//! Code-mode output previews share a rendered-row budget across all result blocks;
//! Tool output previews share a three-row budget across all result blocks;
//! the expanded transcript retains the full text.
use super::*;
@@ -17,9 +17,12 @@ pub(crate) use computer_activity::ComputerActivityCell;
use crate::style::StatusTone;
use crate::style::accent_style;
use crate::style::status_style;
use crate::text_formatting::format_json_compact;
use crate::tool_output::ToolOutputPreview;
use codex_app_server_protocol::McpServerConnectionStatus;
use result::McpResultKind;
use result::McpToolResult;
use std::borrow::Cow;
#[cfg(test)]
#[path = "mcp_tests.rs"]
@@ -203,6 +206,7 @@ impl McpToolCallCell {
let mut detail_lines: Vec<Line<'static>> = Vec::new();
// Reserve four columns for the tree prefix (" └ "/" ") and ensure the wrapper still has at least one cell to work with.
let detail_wrap_width = (width as usize).saturating_sub(4).max(1);
let mut preview = ToolOutputPreview::new(detail_wrap_width, /*omitted*/ 0);
if let Some(result) = &self.result {
match result {
@@ -212,55 +216,59 @@ impl McpToolCallCell {
// Code-mode image blocks can carry text as well. Preserve that text
// without losing the image indication when it replaces the summary.
if node_repl && block.is_image && block.text().is_some() {
detail_lines.extend(
textwrap::wrap("Returned image", detail_wrap_width)
.into_iter()
.map(|line| Line::from(line.into_owned().dim())),
);
if mode == McpToolCallRenderMode::Display {
preview.push_line(Line::from("Returned image".dim()));
} else {
detail_lines.extend(
textwrap::wrap("Returned image", detail_wrap_width)
.into_iter()
.map(|line| Line::from(line.into_owned().dim())),
);
}
}
let text = if compact && status == Some(true) {
let meaningful_output = block.text().and_then(|text| {
if text.starts_with("Script completed\n") {
return text
.split_once("\nOutput:\n")
.map(|(_, output)| output.to_string());
.map(|(_, output)| Cow::Borrowed(output));
}
serde_json::from_str::<NodeReplExecOutput>(text)
.ok()
.filter(|output| output.exit_code == 0)
.map(|output| output.output)
.map(|output| Cow::Owned(output.output))
});
match meaningful_output {
Some(output) if output.is_empty() => continue,
Some(output) => format_and_truncate_tool_result(
&output,
TOOL_CALL_MAX_LINES,
detail_wrap_width,
),
Some(output) => format_json_compact(&output)
.map(Cow::Owned)
.unwrap_or(output),
None => block.text().map_or_else(
|| block.render(detail_wrap_width),
|| block.render(),
|text| {
format_and_truncate_tool_result(
text,
TOOL_CALL_MAX_LINES,
detail_wrap_width,
)
format_json_compact(text)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(text))
},
),
}
} else if node_repl && let Some(output) = block.text() {
if mode == McpToolCallRenderMode::Transcript {
output.trim_end_matches('\n').to_string()
Cow::Borrowed(output.trim_end_matches('\n'))
} else {
format_and_truncate_tool_result(
output,
TOOL_CALL_MAX_LINES,
detail_wrap_width,
)
format_json_compact(output)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(output))
}
} else {
block.render(detail_wrap_width)
block.render()
};
if mode == McpToolCallRenderMode::Display {
for line in text.lines() {
preview.push_line(Line::from(line.dim()));
}
continue;
}
for segment in text.split('\n') {
let line = Line::from(segment.to_string().dim());
let wrapped = adaptive_wrap_line(
@@ -276,43 +284,25 @@ impl McpToolCallCell {
}
Err(err) => {
let err_text = format!("Error: {err}");
let err_text = if node_repl && mode == McpToolCallRenderMode::Transcript {
err_text
if mode == McpToolCallRenderMode::Display {
for line in err_text.lines() {
preview.push_line(Line::from(line.dim()));
}
} else {
format_and_truncate_tool_result(
&err_text,
TOOL_CALL_MAX_LINES,
width as usize,
)
};
let err_line = Line::from(err_text.dim());
let wrapped = adaptive_wrap_line(
&err_line,
RtOptions::new(detail_wrap_width)
.initial_indent("".into())
.subsequent_indent(" ".into()),
);
detail_lines.extend(wrapped.iter().map(line_to_static));
let err_line = Line::from(err_text.dim());
let wrapped = adaptive_wrap_line(
&err_line,
RtOptions::new(detail_wrap_width).subsequent_indent(" ".into()),
);
detail_lines.extend(wrapped.iter().map(line_to_static));
}
}
}
}
if compact {
// Adaptive wrapping keeps URLs intact; split overlong preview rows before counting
// them so a URL cannot exceed the budget. The transcript keeps the original URL.
detail_lines = crate::wrapping::word_wrap_lines(detail_lines, detail_wrap_width);
if detail_lines.len() > TOOL_CALL_MAX_LINES {
// Retain the tail so stdout cannot crowd out a trailing failure diagnostic.
let tail = detail_lines.split_off(detail_lines.len() - TOOL_CALL_MAX_LINES / 2);
detail_lines.truncate((TOOL_CALL_MAX_LINES - 1) / 2);
detail_lines.push(crate::line_truncation::truncate_line_to_width(
"… more · ctrl+t".dim().into(),
detail_wrap_width,
));
detail_lines.extend(tail);
}
if mode == McpToolCallRenderMode::Display {
detail_lines = preview.finish();
}
if !detail_lines.is_empty() {
let initial_prefix: Span<'static> = if inline_invocation {
" └ ".dim()
@@ -350,7 +340,7 @@ impl HistoryCell for McpToolCallCell {
match result {
Ok(McpToolResult { content, .. }) => {
for block in content {
let text = block.render(RAW_TOOL_OUTPUT_WIDTH);
let text = block.render();
lines.extend(raw_lines_from_source(&text));
}
}
+8 -7
View File
@@ -3,8 +3,7 @@
//! The wire result can contain multi-megabyte image, audio, or resource bodies. Validate each
//! block once with the MCP model, then retain only what history rendering actually displays.
use crate::exec_cell::TOOL_CALL_MAX_LINES;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::format_json_compact;
use base64::Engine;
use codex_protocol::mcp::CallToolResult;
use image::DynamicImage;
@@ -127,14 +126,16 @@ impl McpContentBlock {
}
}
/// Applies width-dependent formatting to full text or fallback JSON without reparsing the MCP
/// block. Media and resource summaries retain their existing untruncated display form.
pub(super) fn render(&self, width: usize) -> String {
/// Formats the complete text or fallback JSON. The caller applies a single preview limit
/// across all blocks, while transcript and raw output retain the full result.
pub(super) fn render(&self) -> Cow<'_, str> {
match &self.display {
McpContentDisplay::Text(text) | McpContentDisplay::Json(text) => {
format_and_truncate_tool_result(text, TOOL_CALL_MAX_LINES, width)
format_json_compact(text)
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(text))
}
McpContentDisplay::Summary(summary) => summary.to_string(),
McpContentDisplay::Summary(summary) => Cow::Borrowed(summary),
}
}
}
+87 -40
View File
@@ -133,6 +133,55 @@ fn result(content: Vec<Value>) -> CallToolResult {
}
}
#[test]
fn mcp_preview_shares_one_limit_across_blocks_and_preserves_transcript() {
let mut cell = new_active_mcp_tool_call(
"call-preview".into(),
McpInvocation {
server: "search".into(),
tool: "lookup".into(),
arguments: None,
},
/*animations_enabled*/ false,
);
cell.complete(
Duration::ZERO,
Ok(result(vec![
json!({"type": "text", "text": "first\nsecond"}),
json!({"type": "text", "text": "third\nfourth\nfifth"}),
])),
);
let display = cell.display_lines(/*width*/ 80);
let transcript = cell.transcript_lines(/*width*/ 80);
insta::assert_snapshot!(format!(
"history:\n{}\n\ntranscript:\n{}",
display
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
transcript
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
));
assert_eq!(
cell.raw_lines()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
[
"Called search.lookup()",
"first",
"second",
"third",
"fourth",
"fifth"
],
);
}
#[test]
fn code_mode_output_shares_a_row_budget_across_blocks() {
let mut cell = new_active_mcp_tool_call(
@@ -159,13 +208,12 @@ fn code_mode_output_shares_a_row_budget_across_blocks() {
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(display, @"
insta::assert_snapshot!(display, @r"
• Inspect page
└ Page title
Navigation
… more · ctrl+t
Link
Footer
Main content
+3 lines (ctrl + t to view transcri…
");
let transcript = cell
.transcript_lines(/*width*/ 100)
@@ -178,7 +226,7 @@ fn code_mode_output_shares_a_row_budget_across_blocks() {
}
#[test]
fn code_mode_output_keeps_trailing_failure_diagnostics() {
fn code_mode_output_preserves_trailing_failure_diagnostics_in_transcript() {
let mut cell = new_active_mcp_tool_call(
"browser-error".to_string(),
McpInvocation {
@@ -205,14 +253,20 @@ fn code_mode_output_keeps_trailing_failure_diagnostics() {
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(display, @"
insta::assert_snapshot!(display, @r"
• Inspect page
└ Script failed
Page title
… more · ctrl+t
Script error:
permission denied
Navigation
+6 lines (ctrl + t to view transcri…
");
let transcript = cell
.transcript_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(transcript.ends_with(" Script error:\n permission denied"));
}
#[test]
@@ -243,13 +297,13 @@ fn code_mode_output_row_budget_applies_after_wrapping_and_to_errors() {
cell.complete(Duration::ZERO, completion);
for width in [20, 40, 80] {
let display = cell.display_lines(width);
assert_eq!(display.len(), 1 + TOOL_CALL_MAX_LINES);
assert_eq!(display.len(), 5); // Header, three output rows, and omission hint.
assert!(
display
.iter()
.all(|line| line.width() <= usize::from(width))
);
assert_eq!(display[3].to_string(), " … more · ctrl+t");
assert!(display.last().unwrap().to_string().starts_with(" +"));
let transcript = cell
.transcript_lines(width)
.iter()
@@ -269,7 +323,7 @@ fn code_mode_output_row_budget_applies_after_wrapping_and_to_errors() {
}
#[test]
fn projected_content_preserves_width_dependent_rendering() {
fn projected_content_preserves_full_rendering() {
let text = "{\"result\": [1, 2, 3], \"text\": \"long output 🦀\"}";
let malformed = json!({"type": "image", "data": PNG});
let invalid_metadata =
@@ -290,28 +344,25 @@ fn projected_content_preserves_width_dependent_rendering() {
McpResultKind::Standard,
);
for width in [1, 8, 40, 120, RAW_TOOL_OUTPUT_WIDTH] {
let format_text =
|text: &str| format_and_truncate_tool_result(text, TOOL_CALL_MAX_LINES, width);
assert_eq!(
projected
.content
.iter()
.map(|block| block.render(width))
.collect::<Vec<_>>(),
vec![
format_text(text),
"Returned image".to_string(),
"<audio content>".to_string(),
"embedded resource: file:///text.txt".to_string(),
"embedded resource: file:///blob.bin".to_string(),
"link: file:///linked.txt".to_string(),
format_text(&malformed.to_string()),
format_text(&invalid_metadata.to_string()),
format_text(&unknown.to_string()),
],
);
}
let format_text = |text: &str| format_json_compact(text).unwrap_or_else(|| text.to_owned());
assert_eq!(
projected
.content
.iter()
.map(result::McpContentBlock::render)
.collect::<Vec<_>>(),
vec![
format_text(text),
"Returned image".to_string(),
"<audio content>".to_string(),
"embedded resource: file:///text.txt".to_string(),
"embedded resource: file:///blob.bin".to_string(),
"link: file:///linked.txt".to_string(),
format_text(&malformed.to_string()),
format_text(&invalid_metadata.to_string()),
format_text(&unknown.to_string()),
],
);
}
#[test]
@@ -325,7 +376,7 @@ fn projected_image_marker_still_requires_a_complete_image() {
let projected = McpToolResult::new(result(vec![invalid.clone()]), McpResultKind::Standard);
assert!(!projected.has_image);
assert_eq!(projected.content[0].render(/*width*/ 80), "Returned image");
assert_eq!(projected.content[0].render(), "Returned image");
let projected = McpToolResult::new(result(vec![invalid, valid]), McpResultKind::Standard);
assert!(projected.has_image);
@@ -396,11 +447,7 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
vec![
Line::from("Called node_repl.js({\"title\":\"Inspect results\"})"),
Line::from("Returned image"),
Line::from(format_and_truncate_tool_result(
&unknown.to_string(),
TOOL_CALL_MAX_LINES,
RAW_TOOL_OUTPUT_WIDTH,
)),
Line::from(format_json_compact(&unknown.to_string()).unwrap()),
],
);
-2
View File
@@ -40,7 +40,6 @@ use crate::terminal_hyperlinks::visible_lines;
use crate::test_support::PathBufExt;
#[cfg(test)]
use crate::test_support::test_path_buf;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::truncate_text;
use crate::tooltips;
use crate::ui_consts::LIVE_PREFIX_COLS;
@@ -99,7 +98,6 @@ use unicode_segmentation::UnicodeSegmentation;
use url::Url;
const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000;
const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000;
mod approvals;
mod base;
@@ -0,0 +1,18 @@
---
source: tui/src/history_cell/mcp_tests.rs
expression: "format!(\"history:\\n{}\\n\\ntranscript:\\n{}\",\ndisplay.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),\ntranscript.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),)"
---
history:
• Called search.lookup()
└ first
second
third
+2 lines (ctrl + t to view transcript)
transcript:
• Called search.lookup()
└ first
second
third
fourth
fifth
@@ -1,10 +1,10 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
expression: rendered
---
• Called
└ search.find_docs({"query":"ratatui
styling","limit":3})
Found styling guidance in styles.md and
additional notes in CONTRIBUTING.md.
additional notes in CONTRIBUTING.md.
link: file:///docs/styles.md
@@ -1,5 +1,5 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
expression: rendered
---
• Called
@@ -8,6 +8,6 @@ expression: rendered
ing_to_display_properly_in_the_h
istory","limit":1})
Line one of the response, which is
quite long and needs wrapping.
quite long and needs wrapping.
Line two continues the response with
more detail.
+1 line (ctrl + t to view transcrip…
@@ -1,5 +1,5 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
expression: rendered
---
• Ran echo
@@ -9,4 +9,4 @@ expression: rendered
└ error: first line on
stderr
error: second line on
stderr
+1 line (ctrl + t to vi…
@@ -1,10 +1,9 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
expression: rendered
---
• Ran seq 1 10 1>&2 && false
└ 1
2
… +6 lines (ctrl + t to view transcript)
9
10
3
+7 lines (ctrl + t to view transcript)
+4 -5
View File
@@ -1434,10 +1434,9 @@ fn code_mode_tool_call_uses_title_and_preserves_full_transcript() {
history:
• Inspect Spotify workspace
└ 012345678901234567890123456789012345
67890123456789012345678901234567
… more · ctrl+t
23456789012345678901234567890123
45678901...
678901234567890123456789012345678901
234567890123456789012345678901234567
+1 line (ctrl + t to view transcrip…
transcript:
• Called node_repl.js({"title":"Inspect Spotify workspace","code":"await tools.exec_command({ cmd: 'git status' })"})
@@ -2204,7 +2203,7 @@ fn multiline_command_both_lines_wrap_with_correct_prefixes() {
#[test]
fn stderr_tail_more_than_five_lines_snapshot() {
// Build an exec cell with a non-zero exit and 10 lines on stderr to exercise
// the head/tail rendering and gutter prefixes.
// the three-line preview, hidden-line count, and gutter prefixes.
let call_id = "c_err".to_string();
let mut cell = ExecCell::new(
ExecCall {
+1
View File
@@ -218,6 +218,7 @@ mod theme_picker;
mod thread_color;
mod thread_transcript;
mod token_usage;
mod tool_output;
mod tooltips;
mod transcript_reflow;
mod tui;
@@ -0,0 +1,19 @@
---
source: tui/src/tool_output_tests.rs
expression: "format!(\"history:\\n{}\\n\\ntranscript:\\n{}\",\ncompleted.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),\ncell.transcript_lines(80).iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\"),)"
---
history:
• Ran echo output
└ first
second
third
+2 lines (ctrl + t to view transcript)
transcript:
$ echo output
first
second
third
fourth
fifth
✓ • 0ms
@@ -0,0 +1,8 @@
---
source: tui/src/tool_output_tests.rs
expression: "preview.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\")"
---
first
https://
example.test/a/
+9 lines (ctrl …
-19
View File
@@ -14,25 +14,6 @@ pub(crate) fn capitalize_first(input: &str) -> String {
}
}
/// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating.
/// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells.
pub(crate) fn format_and_truncate_tool_result(
text: &str,
max_lines: usize,
line_width: usize,
) -> String {
// Work out the maximum number of graphemes we can display for a result.
// It's not guaranteed that 1 grapheme = 1 cell, so we subtract 1 per line as a fudge factor.
// It also won't handle future terminal resizes properly, but it's an OK approximation for now.
let max_graphemes = (max_lines * line_width).saturating_sub(max_lines);
if let Some(formatted_json) = format_json_compact(text) {
truncate_text(&formatted_json, max_graphemes)
} else {
truncate_text(text, max_graphemes)
}
}
/// Formats JSON on one line with spaces after separators for readability and natural wrap points.
///
/// Compact JSON is hard to scan, while pretty-printed JSON consumes unnecessary terminal rows.
+105
View File
@@ -0,0 +1,105 @@
//! Compact tool output previews. Keep three screen rows and report hidden logical lines below
//! them; a partially displayed logical line counts as hidden. Full output belongs in the transcript.
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::live_wrap::take_prefix_by_width;
use crate::render::line_utils::push_owned_lines;
use crate::ui_consts::TRANSCRIPT_HINT;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use textwrap::WordSplitter;
const PREVIEW_LINES: usize = 3;
const MAX_PREVIEW_LINE_BYTES: usize = 16 * 1024;
/// Render only the leading lines; `total_lines` includes any lines no longer retained by the caller.
pub(crate) fn tool_output_preview<'a>(
lines: impl IntoIterator<Item = Line<'a>>,
width: usize,
total_lines: usize,
) -> Vec<Line<'static>> {
let mut preview = ToolOutputPreview::new(width, /*omitted*/ 0);
let mut consumed = 0;
for line in lines.into_iter().take(PREVIEW_LINES) {
preview.push_line(line);
consumed += 1;
}
preview.omitted += total_lines.saturating_sub(consumed);
preview.finish()
}
pub(crate) struct ToolOutputPreview {
lines: Vec<Line<'static>>,
width: usize,
omitted: usize,
full: bool,
}
impl ToolOutputPreview {
pub(crate) fn new(width: usize, omitted: usize) -> Self {
Self {
lines: Vec::new(),
width: width.max(1),
omitted,
full: false,
}
}
pub(crate) fn push_line(&mut self, line: Line<'_>) {
let remaining = PREVIEW_LINES - self.lines.len();
if self.full || remaining == 0 {
self.omitted += 1;
return;
}
// Bound the input before wrapping: a single tool-result line can be megabytes long.
// Keep one extra row so cutting a word cannot change the last visible row's wrapping.
let mut columns = self.width.saturating_mul(remaining + 1);
// Zero-width characters still cost bytes and work, even within one grapheme.
let mut bytes = MAX_PREVIEW_LINE_BYTES;
let mut bounded = Line::default().style(line.style);
let mut truncated = false;
for span in &line.spans {
let text = &span.content[..span.content.floor_char_boundary(bytes)];
let (prefix, rest, used) = take_prefix_by_width(text, columns);
bytes -= prefix.len();
bounded.push_span(Span::styled(prefix, span.style));
columns = columns.saturating_sub(used);
if !rest.is_empty() || text.len() < span.content.len() {
truncated = true;
break;
}
}
// Hard-wrap even URL tokens here so the preview cannot exceed its row budget.
// The transcript keeps the original, clickable URLs.
let wrapped = word_wrap_line(
&bounded,
RtOptions::new(self.width).word_splitter(WordSplitter::NoHyphenation),
);
if truncated || wrapped.len() > remaining {
self.omitted += 1;
self.full = true;
}
push_owned_lines(&wrapped[..wrapped.len().min(remaining)], &mut self.lines);
}
pub(crate) fn finish(mut self) -> Vec<Line<'static>> {
if self.omitted > 0 {
let omitted = self.omitted;
let unit = if omitted == 1 { "line" } else { "lines" };
self.lines.push(truncate_line_with_ellipsis_if_overflow(
format!("+{omitted} {unit} ({TRANSCRIPT_HINT})")
.dim()
.into(),
self.width,
));
}
self.lines
}
}
#[cfg(test)]
#[path = "tool_output_tests.rs"]
mod tests;
+118
View File
@@ -0,0 +1,118 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn preview_caps_wrapped_output_and_counts_hidden_logical_lines() {
let lines = [
"first",
"https://example.test/a/very/long/path/that/exceeds/the/preview",
"last",
];
let preview = tool_output_preview(
lines.into_iter().map(Line::from),
/*width*/ 16,
/*total_lines*/ 10,
);
assert_eq!(preview.len(), PREVIEW_LINES + 1);
assert!(
preview[..PREVIEW_LINES]
.iter()
.all(|line| line.width() <= 16)
);
insta::assert_snapshot!(
preview
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn preview_preserves_short_output_and_blank_lines() {
let lines = vec!["first".red().into(), Line::default(), "last".dim().into()];
assert_eq!(
tool_output_preview(lines.clone(), /*width*/ 40, lines.len()),
lines,
);
}
#[test]
fn preview_counts_newline_dense_output() {
let mut rendered = 0;
let preview = tool_output_preview(
std::iter::repeat_n(Line::default(), 100_000).inspect(|_| rendered += 1),
/*width*/ 40,
/*total_lines*/ 100_000,
);
assert_eq!(rendered, PREVIEW_LINES);
assert_eq!(
preview.iter().map(ToString::to_string).collect::<Vec<_>>(),
["", "", "", "+99997 lines (ctrl + t to view transcri…"],
);
}
#[test]
fn preview_caps_combining_text_across_styled_spans() {
let marks = "\u{301}".repeat(MAX_PREVIEW_LINE_BYTES / 4);
let preview = tool_output_preview(
[
Line::from(vec!["e".red(), marks.clone().dim(), marks.clone().blue()]),
"later".into(),
],
/*width*/ 80,
/*total_lines*/ 2,
);
assert_eq!(
preview,
vec![
Line::from(vec![
"e".red(),
marks.clone().dim(),
marks[..marks.len() - 2].blue(),
]),
"+2 lines (ctrl + t to view transcript)".dim().into(),
],
);
}
#[test]
fn command_preview_matches_streamed_and_completed_output() {
use crate::exec_cell::CommandOutput;
use crate::exec_cell::new_active_exec_command;
use crate::history_cell::HistoryCell;
use codex_app_server_protocol::CommandExecutionSource;
use std::time::Duration;
let mut cell = new_active_exec_command(
"call-preview".into(),
vec!["bash".into(), "-lc".into(), "echo output".into()],
Vec::new(),
CommandExecutionSource::Agent,
/*interaction_input*/ None,
/*animations_enabled*/ false,
);
let output = "first\nsecond\nthird\nfourth\nfifth\n";
cell.append_output("call-preview", output);
let live = cell.display_lines(/*width*/ 80);
cell.complete_call(
"call-preview",
CommandOutput::new(/*exit_code*/ 0, output.into()),
Duration::ZERO,
);
let completed = cell.display_lines(/*width*/ 80);
assert_eq!(&live[1..], &completed[1..]);
insta::assert_snapshot!(format!(
"history:\n{}\n\ntranscript:\n{}",
completed
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
cell.transcript_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n"),
));
}