mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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!(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+18
@@ -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
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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…
|
||||
|
||||
+2
-2
@@ -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…
|
||||
|
||||
+3
-4
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
@@ -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
|
||||
+8
@@ -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 …
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user