Make TUI web and image activity summaries compact and descriptive (#46116)

## What changed

- Render web activity on one line with width-aware truncation and labels for searches, opened pages, and in-page searches. Preserve all batched queries in full details.
- Show viewed images by filename in chat while retaining their original paths in expanded transcripts and raw output. Use these activity renderers for persisted transcripts and exports.
- Display code-mode tool titles without the `Calling` or `Called` prefix and truncate headers to the available width while preserving full transcript titles and failure details.
- Keep `Returned image` markers inside the originating MCP call, alongside result text, and use the same wording in transcript exports.

## Testing

Add regression tests and snapshots for narrow displays, missing web action details, batched queries, image paths, MCP image results, matching live and replay rendering, and complete web and image details in persisted exports.

GitOrigin-RevId: 5d3f0a0031818c985378ad3ee4b24084d3868c98
This commit is contained in:
Eric Traut
2026-09-17 04:48:11 +00:00
committed by copyberry
parent 841b5490b2
commit 4b0f19d6f9
30 changed files with 578 additions and 202 deletions
@@ -0,0 +1,17 @@
---
source: tui/src/app/transcript_export_tests.rs
expression: "render_markdown_transcript(&cells).expect(\"exported transcript\")"
---
# Codex conversation
## Activity
Opened https://example.com/long/path?query=full#details
## Activity
Searched for 'needle' in https://example.com/long/path
## Activity
Viewed image C:\workspace\assets\screenshot.png
+1 -1
View File
@@ -162,7 +162,7 @@ fn export_activity_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
lines.extend(raw_lines_from_source(&text.text));
}
Ok(rmcp::model::ContentBlock::Image(_)) => {
lines.push("<image content>".into());
lines.push("Returned image".into());
}
Ok(rmcp::model::ContentBlock::Audio(_)) => {
lines.push("<audio content>".into());
@@ -158,7 +158,7 @@ fn persisted_transcript_includes_file_and_mcp_details() {
r#"{"query":"export"}"#,
"matching docs",
"next line",
"<image content>",
"Returned image",
"<audio content>",
"embedded resource: file:///report.md",
"link: file:///linked.md",
@@ -197,3 +197,28 @@ fn transcript_file_resolves_relative_paths_and_refuses_to_overwrite() {
.expect_err("missing home-relative parent directory");
assert!(error.contains(&dirs::home_dir().expect("home").display().to_string()));
}
#[test]
fn persisted_web_and_image_activity_preserves_full_details() {
let items = [
serde_json::json!({
"type": "webSearch", "id": "open", "query": "",
"action": {"type": "openPage", "url": "https://example.com/long/path?query=full#details"}
}),
serde_json::json!({
"type": "webSearch", "id": "find", "query": "",
"action": {"type": "findInPage", "url": "https://example.com/long/path", "pattern": "needle"}
}),
serde_json::json!({
"type": "imageView", "id": "image", "path": "C:\\workspace\\assets\\screenshot.png"
}),
].into_iter().map(|item| serde_json::from_value::<ThreadItem>(item).expect("valid thread item"));
let cells = crate::thread_transcript::thread_items_to_transcript_cells(
/*thread_id*/ None,
&codex_utils_absolute_path::AbsolutePathBuf::current_dir().expect("cwd"),
items,
crate::thread_transcript::RawReasoningVisibility::Hidden,
/*config*/ None,
);
insta::assert_snapshot!(render_markdown_transcript(&cells).expect("exported transcript"));
}
@@ -1,7 +1,5 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
assertion_line: 877
expression: combined
---
• Viewed Image
└ C:\workspace\assets\example.png
• Viewed image example.png
@@ -1,6 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/exec_flow.rs
expression: combined
---
• Viewed Image
└ example.png
• Viewed image example.png
+2
View File
@@ -282,6 +282,8 @@ mod status_and_layout;
mod status_command_tests;
mod status_surface_previews;
mod terminal_title;
#[path = "tests/tool_activity_tests.rs"]
mod tool_activity_tests;
mod usage;
#[path = "tests/worktree_picker_tests.rs"]
mod worktree_picker;
@@ -1357,7 +1357,7 @@ async fn failed_repl_mcp_tool_call_preserves_status_and_result() {
}
insta::allow_duplicates! {
insta::assert_snapshot!(lines_to_single_string(lines), @r#"
• Called Inspect workspace
• Inspect workspace
└ Script failed
{"exit_code": 0, "output": "ready", "chunk_id": "chunk-1"}
Script error:
@@ -1369,8 +1369,6 @@ async fn failed_repl_mcp_tool_call_preserves_status_and_result() {
Some(&Line::from(vec![
"•".red().bold(),
" ".into(),
"Called".bold(),
" ".into(),
"Inspect workspace".cyan(),
])),
"{server}",
@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests/tool_activity_tests.rs
expression: "outputs[0]"
---
• Inspect screenshot
└ Screenshot captured
Returned image
@@ -0,0 +1,34 @@
use super::*;
#[tokio::test]
async fn image_result_live_and_replay_render_in_the_owning_call() {
let item: AppServerThreadItem = serde_json::from_value(json!({
"type": "mcpToolCall", "id": "image", "server": "node_repl", "tool": "js",
"status": "completed", "arguments": {"title": "Inspect screenshot", "code": "showImage()"},
"result": {"content": [
{"type": "text", "text": "Screenshot captured"},
{"type": "image", "mimeType": "image/png", "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="}
]},
"durationMs": 5
})).expect("MCP image result");
let mut outputs = Vec::new();
for replay in [false, true] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
if replay {
chat.replay_thread_item(item.clone(), "turn-1".into(), ReplayKind::ThreadSnapshot);
} else {
let mut started = item.clone();
if let AppServerThreadItem::McpToolCall { status, result, .. } = &mut started {
*status = codex_app_server_protocol::McpToolCallStatus::InProgress;
*result = None;
}
chat.on_mcp_tool_call_started(started);
chat.on_mcp_tool_call_completed(item.clone());
}
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
outputs.push(lines_to_single_string(&cells[0]));
}
assert_eq!(outputs[0], outputs[1]);
insta::assert_snapshot!(outputs[0]);
}
+3 -10
View File
@@ -13,10 +13,7 @@ impl ChatWidget {
pub(super) fn on_view_image_tool_call(&mut self, path: LegacyAppPathString) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_view_image_tool_call(
path,
&self.config.cwd,
));
self.add_to_history(history_cell::new_view_image_tool_call(path));
self.request_redraw();
}
@@ -248,7 +245,7 @@ impl ChatWidget {
return;
}
let extra_cell = match self
match self
.transcript
.active_cell
.as_mut()
@@ -262,16 +259,12 @@ impl ChatWidget {
invocation,
self.local_settings.tui.animations,
);
let extra_cell = cell.complete(duration, result);
cell.complete(duration, result);
self.transcript.active_cell = Some(Box::new(cell));
extra_cell
}
};
self.flush_active_cell();
if let Some(extra) = extra_cell {
self.add_boxed_history(extra);
}
}
/// Reuse only adjacent computer calls; all other active cells form a transcript boundary.
@@ -31,9 +31,7 @@ impl ComputerActivityCell {
let id = call.call_id.clone();
self.start(call);
if let Some(call) = self.calls.iter_mut().find(|call| call.call_id == id) {
// Images are represented in the action preview and full MCP transcript. Inserting a
// separate image-output history cell here would prematurely break adjacent grouping.
let _ = call.complete(duration, result);
call.complete(duration, result);
}
}
+58 -39
View File
@@ -3,6 +3,7 @@
//! the expanded transcript retains the full text.
use super::*;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use codex_protocol::mcp::is_node_repl_backed_server;
@@ -24,18 +25,6 @@ use result::McpToolResult;
#[path = "mcp_tests.rs"]
mod tests;
#[derive(Debug)]
struct McpImageOutputCell;
impl HistoryCell for McpImageOutputCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec!["tool result (image output)".into()]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from("tool result (image output)")]
}
}
fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str {
match status {
McpAuthStatus::Unknown => "Unknown",
@@ -106,16 +95,10 @@ impl McpToolCallCell {
&mut self,
duration: Duration,
result: Result<codex_protocol::mcp::CallToolResult, String>,
) -> Option<Box<dyn HistoryCell>> {
) {
let result = result.map(|result| McpToolResult::new(result, self.result_kind()));
let image_cell = result
.as_ref()
.ok()
.filter(|result| result.has_image)
.map(|_| Box::new(McpImageOutputCell) as Box<dyn HistoryCell>);
self.duration = Some(duration);
self.result = Some(result);
image_cell
}
fn success(&self) -> Option<bool> {
@@ -167,31 +150,44 @@ impl McpToolCallCell {
"Calling"
};
let title = self
.invocation
.arguments
.as_ref()
.filter(|_| compact)
.and_then(|arguments| arguments.get("title"))
.and_then(serde_json::Value::as_str)
.map(|title| title.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|title| !title.is_empty());
let invocation_line = if compact {
let title = self
.invocation
.arguments
.as_ref()
.and_then(|arguments| arguments.get("title"))
.and_then(serde_json::Value::as_str)
.map(|title| title.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|title| !title.is_empty())
.map(|title| title.graphemes(true).take(80).collect::<String>())
.unwrap_or_else(|| format!("{}.{}", self.invocation.server, self.invocation.tool));
Line::from(title.cyan())
Line::from(
title
.clone()
.unwrap_or_else(|| {
format!("{}.{}", self.invocation.server, self.invocation.tool)
})
.cyan(),
)
} else {
line_to_static(&format_mcp_invocation(&self.invocation))
};
let mut compact_spans = vec![bullet.clone(), " ".into(), header_text.bold(), " ".into()];
let mut compact_spans = vec![bullet.clone(), " ".into()];
if title.is_none() {
compact_spans.extend([header_text.bold(), " ".into()]);
}
let mut compact_header = Line::from(compact_spans.clone());
let reserved = compact_header.width();
let inline_invocation =
invocation_line.width() <= (width as usize).saturating_sub(reserved);
compact || invocation_line.width() <= (width as usize).saturating_sub(reserved);
if inline_invocation {
compact_header.extend(invocation_line.spans.clone());
lines.push(compact_header);
lines.push(if compact {
truncate_line_with_ellipsis_if_overflow(compact_header, width as usize)
} else {
compact_header
});
} else {
compact_spans.pop(); // drop trailing space for standalone header
lines.push(Line::from(compact_spans));
@@ -213,6 +209,15 @@ impl McpToolCallCell {
Ok(McpToolResult { content, .. }) => {
if !content.is_empty() {
for block in content {
// 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())),
);
}
let text = if compact && status == Some(true) {
let meaningful_output = block.text().and_then(|text| {
if text.starts_with("Script completed\n") {
@@ -232,13 +237,27 @@ impl McpToolCallCell {
TOOL_CALL_MAX_LINES,
detail_wrap_width,
),
None => block.render(detail_wrap_width),
None => block.text().map_or_else(
|| block.render(detail_wrap_width),
|text| {
format_and_truncate_tool_result(
text,
TOOL_CALL_MAX_LINES,
detail_wrap_width,
)
},
),
}
} else if node_repl && let Some(output) = block.text() {
if mode == McpToolCallRenderMode::Transcript {
output.trim_end_matches('\n').to_string()
} else {
format_and_truncate_tool_result(
output,
TOOL_CALL_MAX_LINES,
detail_wrap_width,
)
}
} else if node_repl
&& mode == McpToolCallRenderMode::Transcript
&& let Some(output) = block.text()
{
output.trim_end_matches('\n').to_string()
} else {
block.render(detail_wrap_width)
};
+7 -4
View File
@@ -32,6 +32,7 @@ pub(super) struct McpToolResult {
#[derive(Debug)]
pub(super) struct McpContentBlock {
display: McpContentDisplay,
pub(super) is_image: bool,
/// Code mode uses a top-level `text` field even on malformed or non-text blocks.
original_text: Option<String>,
}
@@ -58,6 +59,7 @@ impl McpToolResult {
// Deserialize by reference so malformed blocks remain available for the exact
// JSON fallback. Successful blocks no longer retain their wire representation.
let parsed = ContentBlock::deserialize(&block);
let is_image = matches!(&parsed, Ok(ContentBlock::Image(_)));
let original_text = match (&parsed, kind) {
(Ok(ContentBlock::Text(_)), _) | (_, McpResultKind::Standard) => None,
(_, McpResultKind::NodeRepl) => block
@@ -68,12 +70,12 @@ impl McpToolResult {
let display = match parsed {
Ok(ContentBlock::Text(text)) => McpContentDisplay::Text(text.text),
Ok(ContentBlock::Image(image)) => {
// Keep the marker's existing full-decode validity check, and stop
// decoding after the first valid image, just as the old search did.
// Computer activity previews require a valid image. Stop decoding once
// one has been found; the result text only retains a readable marker.
if !has_image {
has_image = decode_mcp_image(&image.data).is_some();
}
McpContentDisplay::Summary("<image content>".into())
McpContentDisplay::Summary("Returned image".into())
}
Ok(ContentBlock::Audio(_)) => {
McpContentDisplay::Summary("<audio content>".into())
@@ -95,6 +97,7 @@ impl McpToolResult {
};
McpContentBlock {
display,
is_image,
original_text,
}
})
@@ -136,7 +139,7 @@ impl McpContentBlock {
}
}
/// Fully decodes an MCP image before exposing the separate image-output marker.
/// Fully decodes an MCP image before exposing an image preview in computer activity.
///
/// A header-only check would accept images whose decoder rejects their pixel data. Preserve the
/// existing behavior for invalid base64, unknown formats, corrupt images, and data URLs.
+70 -10
View File
@@ -160,7 +160,7 @@ fn code_mode_output_shares_a_row_budget_across_blocks() {
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(display, @"
• Called Inspect page
• Inspect page
└ Page title
Navigation
… more · ctrl+t
@@ -206,7 +206,7 @@ fn code_mode_output_keeps_trailing_failure_diagnostics() {
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(display, @"
• Called Inspect page
• Inspect page
└ Script failed
Page title
… more · ctrl+t
@@ -301,7 +301,7 @@ fn projected_content_preserves_width_dependent_rendering() {
.collect::<Vec<_>>(),
vec![
format_text(text),
"<image content>".to_string(),
"Returned image".to_string(),
"<audio content>".to_string(),
"embedded resource: file:///text.txt".to_string(),
"embedded resource: file:///blob.bin".to_string(),
@@ -325,7 +325,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), "<image content>");
assert_eq!(projected.content[0].render(/*width*/ 80), "Returned image");
let projected = McpToolResult::new(result(vec![invalid, valid]), McpResultKind::Standard);
assert!(projected.has_image);
@@ -350,6 +350,18 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
]);
cell.complete(Duration::ZERO, Ok(tool_result.clone()));
let narrow = cell.display_lines(/*width*/ 16);
assert!(narrow.iter().all(|line| line.width() <= 16));
assert_eq!(
narrow
.iter()
.skip(1)
.take(2)
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec![" └ Returned", " image"],
);
let display = cell
.display_lines(/*width*/ 200)
.iter()
@@ -364,13 +376,15 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
.join("\n");
insta::assert_snapshot!(format!("history:\n{display}\n\ntranscript:\n{transcript}"), @r#"
history:
• Called Inspect results
└ image-side output
• Inspect results
└ Returned image
image-side output
unknown-side output
transcript:
• Called node_repl.js({"title":"Inspect results"})
└ Script completed
└ Returned image
Script completed
Output:
image-side output
Script completed
@@ -381,7 +395,7 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
cell.raw_lines(),
vec![
Line::from("Called node_repl.js({\"title\":\"Inspect results\"})"),
Line::from("<image content>"),
Line::from("Returned image"),
Line::from(format_and_truncate_tool_result(
&unknown.to_string(),
TOOL_CALL_MAX_LINES,
@@ -415,12 +429,14 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
insta::assert_snapshot!(format!("history:\n{display}\n\ntranscript:\n{transcript}"), @r"
history:
• Called cua_repl.js
└ image-side output
└ Returned image
image-side output
unknown-side output
transcript:
• Called cua_repl.js()
└ Script completed
└ Returned image
Script completed
Output:
image-side output
Script completed
@@ -428,3 +444,47 @@ fn code_mode_preserves_text_fields_on_nontext_and_unknown_blocks() {
unknown-side output
");
}
#[test]
fn titled_image_call_keeps_error_and_full_title_when_narrow() {
let title = "Inspect a very long screenshot title 🦀";
let mut cell = new_active_mcp_tool_call(
"call".into(),
McpInvocation {
server: "node_repl".into(),
tool: "js".into(),
arguments: Some(json!({"title": title})),
},
/*animations_enabled*/ false,
);
assert_eq!(
cell.display_lines(/*width*/ 80)[0].to_string(),
format!("• {title}")
);
cell.complete(
Duration::ZERO,
Ok(CallToolResult {
is_error: Some(true),
..result(vec![
json!({"type": "text", "text": "Screenshot partially failed"}),
json!({"type": "image", "mimeType": "image/png", "data": PNG}),
])
}),
);
let lines = cell.display_lines(/*width*/ 32);
assert!(lines[0].width() <= 32);
assert_eq!(lines[0].spans[0].style, "•".red().bold().style);
insta::assert_snapshot!(
lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
);
assert!(cell.raw_lines()[0].to_string().contains(title));
assert!(
cell.transcript_lines(/*width*/ 200)[0]
.to_string()
.contains(title)
);
}
-1
View File
@@ -12,7 +12,6 @@
use crate::diff_model::FileChange;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::OutputLinesParams;
use crate::exec_cell::TOOL_CALL_MAX_LINES;
+47 -12
View File
@@ -1,8 +1,13 @@
//! Patch summaries and image-tool transcript helpers.
use super::*;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use codex_utils_path_uri::LegacyAppPathString;
#[cfg(test)]
#[path = "patches_tests.rs"]
mod tests;
#[derive(Debug)]
pub(crate) struct PatchHistoryCell {
changes: HashMap<PathBuf, FileChange>,
@@ -57,19 +62,49 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
PlainHistoryCell { lines }
}
pub(crate) fn new_view_image_tool_call(path: LegacyAppPathString, cwd: &Path) -> PlainHistoryCell {
let display_path = path
#[derive(Debug)]
pub(crate) struct ViewImageHistoryCell {
filename: String,
path_label: String,
}
impl HistoryCell for ViewImageHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let line = vec![
"• ".dim(),
"Viewed image ".bold(),
self.filename.replace(['\n', '\r', '\t'], " ").dim(),
]
.into();
vec![truncate_line_with_ellipsis_if_overflow(
line,
width as usize,
)]
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
PrefixedWrappedHistoryCell::new(
Line::from(vec!["Viewed image ".bold(), self.path_label.clone().dim()]),
vec!["• ".dim()],
" ",
)
.display_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from(format!("Viewed image {}", self.path_label))]
}
}
pub(crate) fn new_view_image_tool_call(path: LegacyAppPathString) -> ViewImageHistoryCell {
let filename = path
.to_inferred_path_uri()
.and_then(|path| path.to_abs_path().ok())
.map(|path| display_path_for(path.as_path(), cwd))
.unwrap_or_else(|| path.into_string());
let lines: Vec<Line<'static>> = vec![
vec!["• ".dim(), "Viewed Image".bold()].into(),
vec![" └ ".dim(), display_path.dim()].into(),
];
PlainHistoryCell { lines }
.and_then(|path| path.basename())
.unwrap_or_else(|| path.render_for_ui());
ViewImageHistoryCell {
filename,
path_label: path.into_string(),
}
}
pub(crate) fn new_image_generation_call(
@@ -0,0 +1,38 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn viewed_image_retains_original_path_in_details() {
for path in [
"/workspace/assets/example.png",
r"C:\workspace\assets\example.png",
] {
let cell = new_view_image_tool_call(LegacyAppPathString::from_string(path));
assert_eq!(
cell.display_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["• Viewed image example.png"]
);
assert_eq!(
cell.raw_lines(),
vec![Line::from(format!("Viewed image {path}"))]
);
assert_eq!(
cell.transcript_lines(/*width*/ 200)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec![format!("• Viewed image {path}")]
);
}
}
#[test]
fn viewed_image_narrow_summary() {
let cell = new_view_image_tool_call(LegacyAppPathString::from_string(
"/workspace/very-long-screenshot-name.png",
));
insta::assert_snapshot!(cell.display_lines(/*width*/ 32)[0].to_string());
}
+72 -41
View File
@@ -1,36 +1,26 @@
//! Web-search activity history cells.
//! Compact web activity summaries with complete details in the transcript and raw output.
use super::*;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
fn web_search_header(completed: bool) -> &'static str {
if completed {
"Searched the web"
} else {
"Searching the web"
}
}
#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;
fn web_search_action_detail(action: &WebSearchAction) -> String {
match action {
WebSearchAction::Search { query, queries } => {
query.clone().filter(|q| !q.is_empty()).unwrap_or_else(|| {
let items = queries.as_ref();
let first = items
.and_then(|queries| queries.first())
.cloned()
.unwrap_or_default();
if items.is_some_and(|queries| queries.len() > 1) && !first.is_empty() {
format!("{first} ...")
} else {
first
}
})
}
WebSearchAction::Search { query, queries } => query
.clone()
.filter(|q| !q.is_empty())
.unwrap_or_else(|| queries.as_deref().unwrap_or_default().join(", ")),
WebSearchAction::OpenPage { url } => url.clone().unwrap_or_default(),
WebSearchAction::FindInPage { url, pattern } => match (pattern, url) {
WebSearchAction::FindInPage { url, pattern } => match (
pattern.as_deref().filter(|pattern| !pattern.is_empty()),
url.as_deref().filter(|url| !url.is_empty()),
) {
(Some(pattern), Some(url)) => format!("'{pattern}' in {url}"),
(Some(pattern), None) => format!("'{pattern}'"),
(None, Some(url)) => url.clone(),
(None, Some(url)) => url.to_string(),
(None, None) => String::new(),
},
WebSearchAction::Other => String::new(),
@@ -85,6 +75,49 @@ impl WebSearchCell {
pub(crate) fn complete(&mut self) {
self.completed = true;
}
fn summary(&self) -> Line<'static> {
let detail = web_search_detail(self.action.as_ref(), &self.query);
let (header, separator) = match (&self.action, self.completed) {
(Some(WebSearchAction::OpenPage { .. }), completed) => (
match (completed, detail.is_empty()) {
(true, true) => "Opened page",
(true, false) => "Opened",
(false, true) => "Opening page",
(false, false) => "Opening",
},
" ",
),
(Some(WebSearchAction::FindInPage { pattern, .. }), completed) => {
let has_pattern = pattern.as_ref().is_some_and(|pattern| !pattern.is_empty());
(
match (completed, has_pattern) {
(true, true) => "Searched for",
(true, false) => "Searched page",
(false, true) => "Searching for",
(false, false) => "Searching page",
},
" ",
)
}
(None | Some(WebSearchAction::Other), false) if detail.is_empty() => {
("Browsing the web", " ")
}
(Some(WebSearchAction::Search { .. } | WebSearchAction::Other) | None, completed) => (
if completed {
"Searched the web"
} else {
"Searching the web"
},
" for ",
),
};
let mut line = Line::from(header.bold());
if !detail.is_empty() {
line.extend([separator.into(), detail.into()]);
}
line
}
}
impl HistoryCell for WebSearchCell {
@@ -99,26 +132,24 @@ impl HistoryCell for WebSearchCell {
)
.unwrap_or_else(|| "•".dim())
};
let header = web_search_header(self.completed);
let detail = web_search_detail(self.action.as_ref(), &self.query);
let text: Text<'static> = if detail.is_empty() {
Line::from(vec![header.bold()]).into()
} else {
let separator = if self.completed { " for " } else { " " };
Line::from(vec![header.bold(), separator.into(), detail.into()]).into()
};
PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width)
let mut line = Line::from(vec![bullet, " ".into()]);
let mut summary = self.summary();
for span in &mut summary.spans {
span.content = span.content.replace(['\n', '\r', '\t'], " ").into();
}
line.extend(summary);
vec![truncate_line_with_ellipsis_if_overflow(
line,
width as usize,
)]
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
PrefixedWrappedHistoryCell::new(self.summary(), vec!["• ".dim()], " ").display_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let header = web_search_header(self.completed);
let detail = web_search_detail(self.action.as_ref(), &self.query);
if detail.is_empty() {
vec![Line::from(header)]
} else {
let separator = if self.completed { " for " } else { " " };
vec![Line::from(format!("{header}{separator}{detail}"))]
}
plain_lines(vec![self.summary()])
}
}
@@ -0,0 +1,97 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn web_action_labels_and_missing_details() {
let url = "https://example.com/docs";
let actions = [
WebSearchAction::OpenPage {
url: Some(url.into()),
},
WebSearchAction::OpenPage { url: None },
WebSearchAction::FindInPage {
url: Some(url.into()),
pattern: Some("needle".into()),
},
WebSearchAction::FindInPage {
url: None,
pattern: Some("needle".into()),
},
WebSearchAction::FindInPage {
url: Some(url.into()),
pattern: Some(String::new()),
},
WebSearchAction::FindInPage {
url: None,
pattern: None,
},
];
let rendered = actions
.into_iter()
.flat_map(|action| {
new_web_search_call("call".into(), String::new(), action)
.display_lines(/*width*/ 80)
})
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn web_action_compact_display_preserves_transcript_and_raw_details() {
let url = "https://example.com/docs/very-long-page?section=narrowing#details";
let cell = new_web_search_call(
"call".into(),
String::new(),
WebSearchAction::FindInPage {
url: Some(url.into()),
pattern: Some("日本語 🦀 needle".into()),
},
);
let display = cell.display_lines(/*width*/ 32);
assert_eq!(display.len(), 1);
assert!(display[0].width() <= 32);
insta::assert_snapshot!(display[0].to_string());
let full = format!("Searched for '日本語 🦀 needle' in {url}");
assert_eq!(cell.raw_lines(), vec![Line::from(full.clone())]);
assert_eq!(
cell.transcript_lines(/*width*/ 200)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec![format!("• {full}")]
);
}
#[test]
fn pending_web_action_and_legacy_query() {
let cell = new_active_web_search_call(
"call".into(),
String::new(),
/*animations_enabled*/ false,
);
insta::assert_snapshot!(cell.display_lines(/*width*/ 80)[0].to_string(), @"• Browsing the web");
let legacy = new_web_search_call("call".into(), "old query".into(), WebSearchAction::Other);
assert_eq!(
legacy.raw_lines(),
vec![Line::from("Searched the web for old query")]
);
}
#[test]
fn batched_search_retains_each_query() {
let cell = new_web_search_call(
"call".into(),
String::new(),
WebSearchAction::Search {
query: None,
queries: Some(vec!["first query".into(), "second query".into()]),
},
);
assert_eq!(
cell.raw_lines(),
vec![Line::from("Searched the web for first query, second query")]
);
insta::assert_snapshot!(cell.display_lines(/*width*/ 80)[0].to_string(), @"• Searched the web for first query, second query");
}
@@ -12,6 +12,6 @@ expression: transcript
└ reproduced
• Called cua_repl.js({"title":"Capture the reproduced bug","code":"await cua.getState()"})
└ screenshot metadata
<image content>
Returned image
• Called cua_repl.js({"title":"Check the final page","code":"await cua.getState()"})
└ checked
@@ -0,0 +1,7 @@
---
source: tui/src/history_cell/mcp_tests.rs
expression: "lines.iter().map(ToString::to_string).collect::<Vec<_>>().join(\"\\n\")"
---
• Inspect a very long screensho…
└ Screenshot partially failed
Returned image
@@ -0,0 +1,5 @@
---
source: tui/src/history_cell/patches_tests.rs
expression: "cell.display_lines(32)[0].to_string()"
---
• Viewed image very-long-screen…
@@ -0,0 +1,5 @@
---
source: tui/src/history_cell/search_tests.rs
expression: "display[0].to_string()"
---
• Searched for '日本語 🦀 needl…
@@ -0,0 +1,10 @@
---
source: tui/src/history_cell/search_tests.rs
expression: rendered
---
• Opened https://example.com/docs
• Opened page
• Searched for 'needle' in https://example.com/docs
• Searched for 'needle'
• Searched page https://example.com/docs
• Searched page
@@ -0,0 +1,6 @@
---
source: tui/src/history_cell/tests.rs
expression: rendered
---
• Called image.generate({"prompt":"tiny image"})
└ Returned image
@@ -0,0 +1,7 @@
---
source: tui/src/history_cell/tests.rs
expression: rendered
---
• Called image.generate({"prompt":"tiny image"})
└ Here is the image:
Returned image
@@ -0,0 +1,7 @@
---
source: tui/src/history_cell/tests.rs
expression: rendered
---
• Called image.generate({"prompt":"tiny image"})
└ Returned image
Returned image
@@ -1,6 +1,5 @@
---
source: tui/src/history_cell.rs
source: tui/src/history_cell/tests.rs
expression: rendered
---
• Searched the web for example search query with several generic
words to exercise wrapping
• Searched the web for example search query with several generi…
+29 -60
View File
@@ -497,10 +497,7 @@ fn structured_tool_cell_renders_raw_plain_text_without_prefix_or_style() {
invocation,
/*animations_enabled*/ false,
);
assert!(
cell.complete(Duration::from_millis(1), Ok(result))
.is_none()
);
cell.complete(Duration::from_millis(1), Ok(result));
let lines = cell.raw_lines();
let rendered = render_lines(&lines);
@@ -520,18 +517,14 @@ fn raw_mode_toggle_transcript_snapshot() {
},
/*animations_enabled*/ false,
);
assert!(
tool_cell
.complete(
Duration::from_millis(5),
Ok(CallToolResult {
content: vec![text_block("structured output\nsecond line")],
is_error: None,
structured_content: None,
meta: None,
}),
)
.is_none()
tool_cell.complete(
Duration::from_millis(5),
Ok(CallToolResult {
content: vec![text_block("structured output\nsecond line")],
is_error: None,
structured_content: None,
meta: None,
}),
);
let cells: Vec<Box<dyn HistoryCell>> = vec![
Box::new(new_user_prompt(
@@ -1330,7 +1323,7 @@ fn web_search_history_cell_without_detail_snapshot() {
}
#[test]
fn web_search_history_cell_wraps_with_indented_continuation() {
fn web_search_history_cell_truncates() {
let query = "example search query with several generic words to exercise wrapping".to_string();
let cell = new_web_search_call(
"call-1".to_string(),
@@ -1344,10 +1337,7 @@ fn web_search_history_cell_wraps_with_indented_continuation() {
assert_eq!(
rendered,
vec![
"• Searched the web for example search query with several generic".to_string(),
" words to exercise wrapping".to_string(),
]
vec!["• Searched the web for example search query with several generi…".to_string(),]
);
}
@@ -1441,7 +1431,7 @@ fn code_mode_tool_call_uses_title_and_preserves_full_transcript() {
let transcript = render_lines(&cell.transcript_lines(/*width*/ 180)).join("\n");
insta::assert_snapshot!(format!("history:\n{history}\n\ntranscript:\n{transcript}"), @r#"
history:
• Called Inspect Spotify workspace
• Inspect Spotify workspace
└ 012345678901234567890123456789012345
67890123456789012345678901234567
… more · ctrl+t
@@ -1484,7 +1474,7 @@ fn code_mode_tool_call_preserves_failure_details() {
let transcript = render_lines(&cell.transcript_lines(/*width*/ 120)).join("\n");
insta::assert_snapshot!(format!("history:\n{history}\n\ntranscript:\n{transcript}"), @r#"
history:
• Called Inspect workspace
• Inspect workspace
└ Script failed
Output:
permission denied
@@ -1546,10 +1536,7 @@ fn completed_mcp_tool_call_success_snapshot() {
invocation,
/*animations_enabled*/ true,
);
assert!(
cell.complete(Duration::from_millis(1420), Ok(result))
.is_none()
);
cell.complete(Duration::from_millis(1420), Ok(result));
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
@@ -1557,7 +1544,7 @@ fn completed_mcp_tool_call_success_snapshot() {
}
#[test]
fn completed_mcp_tool_call_image_after_text_returns_extra_cell() {
fn completed_mcp_tool_call_image_after_text_snapshot() {
let invocation = McpInvocation {
server: "image".into(),
tool: "generate".into(),
@@ -1581,12 +1568,10 @@ fn completed_mcp_tool_call_image_after_text_returns_extra_cell() {
invocation,
/*animations_enabled*/ true,
);
let extra_cell = cell
.complete(Duration::from_millis(25), Ok(result))
.expect("expected image cell");
cell.complete(Duration::from_millis(25), Ok(result));
let rendered = render_lines(&extra_cell.display_lines(/*width*/ 80));
assert_eq!(rendered, vec!["tool result (image output)"]);
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
@@ -1612,16 +1597,14 @@ fn completed_mcp_tool_call_accepts_data_url_image_blocks() {
invocation,
/*animations_enabled*/ true,
);
let extra_cell = cell
.complete(Duration::from_millis(25), Ok(result))
.expect("expected image cell");
cell.complete(Duration::from_millis(25), Ok(result));
let rendered = render_lines(&extra_cell.display_lines(/*width*/ 80));
assert_eq!(rendered, vec!["tool result (image output)"]);
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn completed_mcp_tool_call_skips_invalid_image_blocks() {
fn completed_mcp_tool_call_multiple_image_blocks_snapshot() {
let invocation = McpInvocation {
server: "image".into(),
tool: "generate".into(),
@@ -1642,12 +1625,10 @@ fn completed_mcp_tool_call_skips_invalid_image_blocks() {
invocation,
/*animations_enabled*/ true,
);
let extra_cell = cell
.complete(Duration::from_millis(25), Ok(result))
.expect("expected image cell");
cell.complete(Duration::from_millis(25), Ok(result));
let rendered = render_lines(&extra_cell.display_lines(/*width*/ 80));
assert_eq!(rendered, vec!["tool result (image output)"]);
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
@@ -1666,10 +1647,7 @@ fn completed_mcp_tool_call_error_snapshot() {
invocation,
/*animations_enabled*/ true,
);
assert!(
cell.complete(Duration::from_secs(2), Err("network timeout".into()))
.is_none()
);
cell.complete(Duration::from_secs(2), Err("network timeout".into()));
let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n");
@@ -1709,10 +1687,7 @@ fn completed_mcp_tool_call_multiple_outputs_snapshot() {
invocation,
/*animations_enabled*/ true,
);
assert!(
cell.complete(Duration::from_millis(640), Ok(result))
.is_none()
);
cell.complete(Duration::from_millis(640), Ok(result));
let rendered = render_lines(&cell.display_lines(/*width*/ 48)).join("\n");
@@ -1744,10 +1719,7 @@ fn completed_mcp_tool_call_wrapped_outputs_snapshot() {
invocation,
/*animations_enabled*/ true,
);
assert!(
cell.complete(Duration::from_millis(1280), Ok(result))
.is_none()
);
cell.complete(Duration::from_millis(1280), Ok(result));
let rendered = render_lines(&cell.display_lines(/*width*/ 40)).join("\n");
@@ -1780,10 +1752,7 @@ fn completed_mcp_tool_call_multiple_outputs_inline_snapshot() {
invocation,
/*animations_enabled*/ true,
);
assert!(
cell.complete(Duration::from_millis(320), Ok(result))
.is_none()
);
cell.complete(Duration::from_millis(320), Ok(result));
let rendered = render_lines(&cell.display_lines(/*width*/ 120)).join("\n");
+15 -7
View File
@@ -185,6 +185,19 @@ pub(crate) fn thread_items_to_transcript_cells(
)));
}
}
ThreadItem::WebSearch(item) => {
cells.push(Arc::new(crate::history_cell::new_web_search_call(
item.id,
item.query,
item.action
.unwrap_or(codex_app_server_protocol::WebSearchAction::Other),
)));
}
ThreadItem::ImageView { path, .. } => {
cells.push(Arc::new(crate::history_cell::new_view_image_tool_call(
path,
)));
}
other => {
if let Some(cell) = fallback_transcript_cell(&other) {
cells.push(Arc::new(cell));
@@ -274,13 +287,6 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
} => {
vec![sub_agent_activity_summary(*kind, agent_path).dim().into()]
}
ThreadItem::WebSearch(item) => {
vec![vec!["web search: ".dim(), item.query.clone().into()].into()]
}
ThreadItem::ImageView { path, .. } => {
let path = path.render_for_ui();
vec![format!("image: {path}").dim().into()]
}
ThreadItem::ImageGeneration(item) => {
let saved = item
.saved_path
@@ -307,6 +313,8 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
| ThreadItem::FunctionCallOutput { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::WebSearch(_)
| ThreadItem::ImageView { .. }
| ThreadItem::Sleep(_) => return None,
};
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))