Restore rich tool details in persisted TUI transcripts (#46710)

## Why

Loaded transcripts reduce several tool calls and file changes to basic status summaries, losing details available in live history cells.

## What changed

- Reuse rich history cells for completed commands, MCP calls, patches, agent activity, image generation, and notices.
- Add dynamic tool cells with bounded output previews while retaining arguments and full output in detailed and raw views.
- Preserve last-known statuses for incomplete calls, unavailable results, and declined or failed operations. Keep legacy command text and terminal interaction output when rich reconstruction is unavailable.
- Remove redundant move annotations from patch diffs and retain patch activity IDs.
- Filter untrusted control characters before emitting terminal scrollback, while preserving semantic hyperlinks.

## Testing

Add unit and snapshot coverage for compact and detailed tool rendering, status fallbacks, retained output, patch restoration, and control-character filtering with and without hyperlinks.

GitOrigin-RevId: 73d754262768cf6d91f3ea4040116bf93cf87784
This commit is contained in:
Eric Traut
2026-09-19 20:09:58 +00:00
committed by copyberry
parent 321c50fc2c
commit 4abcb8d1da
18 changed files with 1689 additions and 231 deletions

View File

@@ -30,7 +30,7 @@ pub(crate) fn file_update_changes_to_display(
) -> HashMap<PathBuf, FileChange> {
changes
.into_iter()
.map(|change| {
.map(|mut change| {
let path = PathBuf::from(change.path);
let file_change = match change.kind {
PatchChangeKind::Add => FileChange::Add {
@@ -39,10 +39,19 @@ pub(crate) fn file_update_changes_to_display(
PatchChangeKind::Delete => FileChange::Delete {
content: change.diff,
},
PatchChangeKind::Update { move_path } => FileChange::Update {
unified_diff: change.diff,
move_path,
},
PatchChangeKind::Update { move_path } => {
if let Some(path) = &move_path
&& let Some(diff) = change
.diff
.strip_suffix(&format!("\n\nMoved to: {}", path.display()))
{
change.diff.truncate(diff.len());
}
FileChange::Update {
unified_diff: change.diff,
move_path,
}
}
};
(path, file_change)
})

View File

@@ -0,0 +1,275 @@
//! Dynamic tool activity with retained arguments and output across live and loaded transcripts.
use super::HistoryCell;
use super::activity_preview::DETAIL_PREVIEW_LINES;
use super::activity_preview::clipped_line;
use super::raw_lines_from_source;
use crate::style::accent_color;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
use crate::terminal_hyperlinks::visible_lines;
use crate::wrapping::RtOptions;
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
use codex_app_server_protocol::DynamicToolCallStatus;
use codex_app_server_protocol::ThreadItem;
use ratatui::style::Stylize;
use ratatui::text::Line;
use serde_json::Value;
use std::sync::Arc;
use std::sync::RwLock;
#[derive(Clone, Debug)]
pub(crate) struct DynamicToolCallCell {
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
call_id: String,
data: Arc<RwLock<DynamicToolCallData>>,
}
#[derive(Debug)]
struct DynamicToolCallData {
name: String,
arguments: Value,
status: DynamicToolCallStatus,
interrupted: bool,
output: Option<Vec<String>>,
duration_ms: Option<i64>,
}
impl DynamicToolCallCell {
pub(crate) fn from_item(item: ThreadItem) -> Option<Self> {
let (call_id, data) = DynamicToolCallData::from_item(item)?;
Some(Self {
call_id,
data: Arc::new(RwLock::new(data)),
})
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn call_id(&self) -> &str {
&self.call_id
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn is_active(&self) -> bool {
self.data
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_active()
}
/// Updates the original retained row when concurrent calls complete in a different order.
/// Callers revising an already-terminal cell must rebuild its cached renderables.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn update_from_item(&self, item: ThreadItem) -> bool {
let Some((call_id, data)) = DynamicToolCallData::from_item(item) else {
return false;
};
if call_id != self.call_id {
return false;
}
*self
.data
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = data;
true
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn mark_interrupted(&self) {
let mut data = self
.data
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !data.is_active() {
return;
}
data.status = DynamicToolCallStatus::Failed;
data.interrupted = true;
data.output
.get_or_insert_with(Vec::new)
.push("Interrupted before this tool returned a result.".to_owned());
}
}
impl DynamicToolCallData {
fn from_item(item: ThreadItem) -> Option<(String, Self)> {
let ThreadItem::DynamicToolCall {
id,
namespace,
tool,
arguments,
status,
content_items,
success,
duration_ms,
} = item
else {
return None;
};
Some((
id,
Self {
name: namespace
.map_or_else(|| tool.clone(), |namespace| format!("{namespace}.{tool}")),
arguments,
status: if success == Some(false) {
DynamicToolCallStatus::Failed
} else {
status
},
interrupted: false,
output: content_items.map(|items| {
items
.into_iter()
.map(|item| match item {
DynamicToolCallOutputContentItem::InputText { text } => text,
DynamicToolCallOutputContentItem::InputImage { .. } => {
"<image content>".to_owned()
}
DynamicToolCallOutputContentItem::InputAudio { .. } => {
"<audio content>".to_owned()
}
})
.collect()
}),
duration_ms,
},
))
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn is_active(&self) -> bool {
matches!(self.status, DynamicToolCallStatus::InProgress)
}
fn header(&self) -> Line<'static> {
let (marker, verb) = if self.interrupted {
("".red().bold(), "Interrupted")
} else {
match self.status {
DynamicToolCallStatus::InProgress => ("".dim(), "Calling"),
DynamicToolCallStatus::Completed => ("".green(), "Called"),
DynamicToolCallStatus::Failed => ("".red().bold(), "Failed"),
}
};
let mut line = Line::from(vec![
marker,
" ".into(),
verb.bold(),
" ".into(),
self.name.clone().fg(accent_color()),
]);
if let Some(duration_ms) = self.duration_ms.filter(|duration| *duration >= 0) {
line.push_span(format!(" · {duration_ms}ms").dim());
}
line
}
fn output_lines(&self) -> Vec<Line<'static>> {
match &self.output {
Some(output) if output.is_empty() => vec![Line::from("(no output)".dim())],
Some(output) => output
.iter()
.flat_map(|text| raw_lines_from_source(text))
.collect(),
None if self.is_active() => Vec::new(),
None => vec![Line::from("(output unavailable)".dim())],
}
}
}
impl HistoryCell for DynamicToolCallCell {
fn has_stable_transcript_height(&self) -> bool {
!self.is_active()
}
fn activity_ids(&self) -> Vec<String> {
vec![format!("dynamic:{}", self.call_id)]
}
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.compact_hyperlink_lines(width))
}
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let data = self
.data
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut lines = vec![clipped_line(data.header(), width)];
let (skip, output) = match &data.output {
Some(output) if !output.is_empty() => {
let output = output.iter().flat_map(|text| text.split_terminator('\n'));
let skip = output.clone().count().saturating_sub(DETAIL_PREVIEW_LINES);
let tail = output
.skip(skip)
.map(|text| {
// Bound allocation and grapheme scanning before clipping to display width.
let end = text.floor_char_boundary(16 * 1024);
let mut text_preview = text[..end].to_owned();
if end < text.len() {
text_preview.push('…');
}
Line::from(text_preview)
})
.collect();
(skip, tail)
}
_ => (0, data.output_lines()),
};
if skip > 0 {
lines.push(clipped_line(
format!("{skip} earlier lines hidden").dim().into(),
width,
));
}
for (index, mut line) in output.into_iter().enumerate() {
line.spans.insert(
/*index*/ 0,
if index == 0 { "" } else { " " }.dim(),
);
lines.push(clipped_line(line.dim(), width));
}
lines
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
let data = self
.data
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut lines = vec![data.header().into()];
let args = serde_json::to_string_pretty(&data.arguments)
.unwrap_or_else(|_| data.arguments.to_string());
let details = raw_lines_from_source(&format!("Arguments: {args}"))
.into_iter()
.chain(data.output_lines())
.collect();
lines.extend(adaptive_wrap_hyperlink_lines(
&plain_hyperlink_lines(details),
RtOptions::new(usize::from(width).max(/*other*/ 1))
.initial_indent("".dim().into())
.subsequent_indent(" ".into()),
));
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let data = self
.data
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut lines = vec![data.header()];
lines.extend(raw_lines_from_source(&format!(
"Arguments: {}",
data.arguments
)));
lines.extend(data.output_lines());
super::plain_lines(lines)
}
}
#[cfg(test)]
#[path = "dynamic_tests.rs"]
mod tests;

View File

@@ -0,0 +1,116 @@
//! Dynamic tool projections keep truthful status and bounded previews without losing detail.
use super::*;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::thread_items_to_transcript_cells;
use pretty_assertions::assert_eq;
use serde_json::json;
fn item(status: DynamicToolCallStatus, output: Option<&str>) -> ThreadItem {
ThreadItem::DynamicToolCall {
id: "dynamic-1".to_string(),
namespace: Some("example".to_string()),
tool: "inspect".to_string(),
arguments: json!({"path": "src/main.rs"}),
status,
content_items: output.map(|text| {
vec![DynamicToolCallOutputContentItem::InputText {
text: text.to_string(),
}]
}),
success: None,
duration_ms: None,
}
}
#[test]
fn dynamic_status_and_output_match_persisted_presentations() {
let cwd = test_path_buf("/workspace").abs();
let mut snapshots = Vec::new();
for (label, status, output) in [
("pending", DynamicToolCallStatus::InProgress, None),
(
"success",
DynamicToolCallStatus::Completed,
Some("Found the definition"),
),
(
"failure",
DynamicToolCallStatus::Failed,
Some("Permission denied"),
),
("unavailable", DynamicToolCallStatus::Completed, None),
] {
let replayed = thread_items_to_transcript_cells(
/*thread_id*/ None,
&cwd,
[item(status, output)],
RawReasoningVisibility::Hidden,
/*config*/ None,
);
assert_eq!(replayed.len(), 1);
let cell = &replayed[0];
for (mode, lines) in [
("compact", cell.display_lines(/*width*/ 80)),
(
"full",
visible_lines(cell.transcript_hyperlink_lines(/*width*/ 80)),
),
("raw", cell.raw_lines()),
] {
let text = lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
if mode == "compact" || label == "success" {
snapshots.push(format!("{label}, {mode}\n{text}"));
}
}
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn dynamic_preview_reports_hidden_lines_and_retains_full_output() {
let mut snapshots = Vec::new();
for count in [3, 4] {
let output = (1..=count)
.map(|index| format!("Result line {index}: retained content"))
.collect::<Vec<_>>()
.join("\n");
let cell =
DynamicToolCallCell::from_item(item(DynamicToolCallStatus::Completed, Some(&output)))
.unwrap();
for width in [20, 80] {
let compact = cell.display_lines(width);
assert!(
compact
.iter()
.all(|line| line.width() <= usize::from(width))
);
let text = compact
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
snapshots.push(format!("lines={count}, width={width}\n{text}"));
}
let raw = cell
.raw_lines()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(raw.ends_with(&output));
let detailed = visible_lines(cell.transcript_hyperlink_lines(/*width*/ 80))
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(output.lines().all(|line| detailed.contains(line)));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}

View File

@@ -106,6 +106,7 @@ mod activity_details;
pub(crate) mod activity_preview;
mod approvals;
mod base;
mod dynamic;
mod exec;
mod hook_cell;
mod markdown_render_cache;
@@ -125,6 +126,7 @@ mod warnings;
pub(crate) use activity_details::ActivityDetails;
pub(crate) use approvals::*;
pub(crate) use base::*;
pub(crate) use dynamic::DynamicToolCallCell;
pub(crate) use exec::*;
pub(crate) use hook_cell::HookCell;
pub(crate) use hook_cell::new_active_hook_cell;

View File

@@ -17,6 +17,13 @@ pub(crate) struct PatchHistoryCell {
cwd: PathBuf,
}
impl PatchHistoryCell {
pub(crate) fn with_activity_id(mut self, id: String) -> Self {
self.activity_id = format!("patch:{id}");
self
}
}
impl HistoryCell for PatchHistoryCell {
fn activity_ids(&self) -> Vec<String> {
vec![self.activity_id.clone()]

View File

@@ -0,0 +1,30 @@
---
source: tui/src/history_cell/dynamic_tests.rs
assertion_line: 95
expression: "snapshots.join(\"\\n\\n\")"
---
lines=3, width=20
• Called example.in…
└ Result line 1: …
Result line 2: …
Result line 3: …
lines=3, width=80
• Called example.inspect
└ Result line 1: retained content
Result line 2: retained content
Result line 3: retained content
lines=4, width=20
• Called example.in…
… 1 earlier lines…
└ Result line 2: …
Result line 3: …
Result line 4: …
lines=4, width=80
• Called example.inspect
… 1 earlier lines hidden
└ Result line 2: retained content
Result line 3: retained content
Result line 4: retained content

View File

@@ -0,0 +1,31 @@
---
source: tui/src/history_cell/dynamic_tests.rs
assertion_line: 81
expression: "snapshots.join(\"\\n\\n\")"
---
pending, compact
• Calling example.inspect
success, compact
• Called example.inspect
└ Found the definition
success, full
• Called example.inspect
└ Arguments: {
"path": "src/main.rs"
}
Found the definition
success, raw
• Called example.inspect
Arguments: {"path":"src/main.rs"}
Found the definition
failure, compact
• Failed example.inspect
└ Permission denied
unavailable, compact
• Called example.inspect
└ (output unavailable)

View File

@@ -1,7 +1,8 @@
//! Inserts finalized history rows into terminal scrollback.
//!
//! Codex uses the terminal scrollback itself for finalized chat history, so inserting a history
//! cell is an escape-sequence operation rather than a normal ratatui render.
//! cell is an escape-sequence operation rather than a normal ratatui render. Untrusted content
//! follows ratatuis control-character filtering before semantic hyperlinks add trusted escapes.
use std::fmt;
use std::io;
@@ -528,16 +529,38 @@ mod tests {
}
#[test]
fn writes_semantic_web_link_without_changing_visible_text() {
fn writes_semantic_web_link_without_emitting_untrusted_controls() {
use pretty_assertions::assert_eq;
let destination = "https://example.com/long/path";
let line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(destination));
let mut actual = Vec::new();
write_history_line(&mut actual, &line, /*wrap_width*/ 80).expect("write history line");
let output = String::from_utf8(actual).expect("UTF-8 terminal output");
assert!(output.contains("\x1b]8;;https://example.com/long/path\x07"));
assert_eq!(line.line.spans[0].content, destination);
for linked in [false, true] {
let mut line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(vec![
"\x1b[2J\x1b]52;c;Y2xpcA==\x07\u{009d}hidden\u{009c}\r\n\t ".into(),
destination.into(),
]));
let mut safe = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(vec![
"[2J]52;c;Y2xpcA==hidden ".into(),
destination.into(),
]));
if !linked {
line.hyperlinks.clear();
safe.hyperlinks.clear();
}
let original = line.clone();
let mut actual = Vec::new();
let mut expected = Vec::new();
write_history_line(&mut actual, &line, /*wrap_width*/ 80).unwrap();
write_history_line(&mut expected, &safe, /*wrap_width*/ 80).unwrap();
assert_eq!(actual, expected);
assert_eq!(line, original);
let output = String::from_utf8(actual).unwrap();
assert_eq!(
output.contains(&format!(
"\x1b]8;;{destination}\x07{destination}\x1b]8;;\x07"
)),
linked,
);
}
}
#[test]

View File

@@ -649,7 +649,23 @@ pub(crate) fn strip_osc8(text: &str) -> String {
pub(crate) fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
if line.hyperlinks.is_empty() {
return line.line.spans.clone();
return line
.line
.spans
.iter()
.map(|span| Span {
style: span.style,
content: if span.content.contains(char::is_control) {
span.content
.graphemes(/*is_extended*/ true)
.filter(|grapheme| !grapheme.contains(char::is_control))
.collect::<String>()
.into()
} else {
span.content.clone()
},
})
.collect();
}
let mut out = Vec::new();
@@ -660,6 +676,12 @@ pub(crate) fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
for span in &line.line.spans {
for grapheme in span.content.graphemes(/*is_extended*/ true) {
let width = display_width(grapheme);
// Match ratatui's filtering, retaining source columns for semantic links.
// Only the hyperlink metadata below may introduce terminal escapes.
if grapheme.contains(char::is_control) {
column += width;
continue;
}
while line
.hyperlinks
.get(link_index)

View File

@@ -1,4 +1,4 @@
//! Render persisted thread turns into history-cell building blocks.
//! Project individual persisted items into rich history cells without changing live state.
use std::sync::Arc;
@@ -14,7 +14,6 @@ use crate::history_cell::UserHistoryCell;
use crate::history_cell::split_reasoning_summary_parts;
use crate::inline_visualization::InlineVisualizationContext;
use crate::legacy_core::config::Config;
use crate::multi_agents::sub_agent_activity_summary;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::UserInput;
@@ -22,7 +21,9 @@ use codex_protocol::ThreadId;
use codex_protocol::items::UserMessageItem;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
mod other_items;
pub(crate) mod tools;
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
@@ -94,235 +95,137 @@ pub(crate) fn thread_items_to_transcript_cells(
});
let mut cells: TranscriptCells = Vec::new();
for item in items {
match item {
ThreadItem::UserMessage {
id,
client_id,
content,
} => {
if content.iter().any(|input| {
matches!(
input,
UserInput::Audio { .. } | UserInput::LocalAudio { .. }
)
}) {
tracing::warn!(
user_message_id = id,
"audio user inputs are not supported by the TUI and will be omitted"
);
}
let item = UserMessageItem {
id,
client_id,
content: content
.into_iter()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
};
let message = item.message();
let reply_text = crate::async_question_reply::display_text(&message);
let text_elements = if reply_text.is_some() {
Vec::new()
} else {
item.text_elements()
};
cells.push(Arc::new(UserHistoryCell {
spoken: false,
message: reply_text.unwrap_or(message),
text_elements,
local_image_paths: item.local_image_paths(),
remote_image_urls: item.image_urls(),
}));
}
ThreadItem::AgentMessage { text, .. } => {
let parsed = parse_assistant_markdown(&text, cwd.as_path());
if !parsed.visible_markdown.trim().is_empty() {
cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations(
parsed.visible_markdown,
cwd.as_path(),
inline_visualization_context.clone(),
)));
}
}
ThreadItem::FunctionCallOutput {
name,
namespace,
output,
..
} => {
if let Some((source_thread_id, prompt)) =
crate::dynamic_tools::parse_delegated_tool_output(
&name,
namespace.as_deref(),
&output,
)
{
cells.push(Arc::new(PrefixedWrappedHistoryCell::new(
format!("Sent by Codex from task {source_thread_id}\n{prompt}"),
"".dim(),
" ",
)));
}
}
ThreadItem::Plan { text, .. } => {
if !text.trim().is_empty() {
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
text,
cwd.as_path(),
)));
}
}
ThreadItem::Reasoning {
summary, content, ..
} => {
let (header, text) =
if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
&& !content.is_empty()
{
("Reasoning".to_string(), content.join("\n\n"))
} else {
split_reasoning_summary_parts(&summary)
};
if !text.trim().is_empty() {
cells.push(Arc::new(ReasoningSummaryCell::new(
header,
text,
cwd.as_path(),
/*transcript_only*/ false,
)));
}
}
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));
}
}
if let Some(cell) = tools::historical_tool_fallback(&item) {
cells.push(Arc::new(cell));
} else {
cells.extend(item_to_cells(
item,
cwd,
raw_reasoning_visibility,
inline_visualization_context.clone(),
));
}
}
cells
}
fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
let lines = match item {
ThreadItem::HookPrompt { fragments, .. } => fragments
.iter()
.map(|fragment| {
vec![
"hook prompt: ".dim(),
fragment.text.trim().to_string().into(),
]
.into()
})
.collect::<Vec<_>>(),
ThreadItem::CommandExecution {
command,
status,
aggregated_output,
exit_code,
..
/// Project one item without changing the active widget or its turn lifecycle.
fn item_to_cells(
item: ThreadItem,
cwd: &AbsolutePathBuf,
raw_reasoning_visibility: RawReasoningVisibility,
inline_visualization_context: Option<InlineVisualizationContext>,
) -> TranscriptCells {
let mut cells: TranscriptCells = Vec::new();
match item {
ThreadItem::UserMessage {
id,
client_id,
content,
} => {
let mut lines: Vec<Line<'static>> =
vec![vec!["$ ".dim(), command.clone().into()].into()];
lines.push(
format!(
"status: {status:?}{}",
exit_code
.map(|code| format!(" · exit {code}"))
.unwrap_or_default()
if content.iter().any(|input| {
matches!(
input,
UserInput::Audio { .. } | UserInput::LocalAudio { .. }
)
.dim()
.into(),
);
if let Some(output) = aggregated_output.as_deref()
&& !output.trim().is_empty()
{
lines.extend(
output
.lines()
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
}) {
tracing::warn!(
user_message_id = id,
"audio user inputs are not supported by the TUI and will be omitted"
);
}
lines
let item = UserMessageItem {
id,
client_id,
content: content
.into_iter()
.map(codex_app_server_protocol::UserInput::into_core)
.collect(),
};
let message = item.message();
let reply_text = crate::async_question_reply::display_text(&message);
let text_elements = if reply_text.is_some() {
Vec::new()
} else {
item.text_elements()
};
cells.push(Arc::new(UserHistoryCell {
spoken: false,
message: reply_text.unwrap_or(message),
text_elements,
local_image_paths: item.local_image_paths(),
remote_image_urls: item.image_urls(),
}));
}
ThreadItem::FileChange {
changes, status, ..
} => vec![
format!("file changes: {status:?} · {} changes", changes.len())
.dim()
.into(),
],
ThreadItem::McpToolCall {
server,
tool,
status,
..
} => vec![
format!("mcp tool: {server}/{tool} · {status:?}")
.dim()
.into(),
],
ThreadItem::DynamicToolCall {
ThreadItem::AgentMessage { text, .. } => {
let parsed = parse_assistant_markdown(&text, cwd.as_path());
if !parsed.visible_markdown.trim().is_empty() {
cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations(
parsed.visible_markdown,
cwd.as_path(),
inline_visualization_context,
)));
}
}
ThreadItem::FunctionCallOutput {
name,
namespace,
tool,
status,
output,
..
} => {
let name = namespace
.as_ref()
.map(|namespace| format!("{namespace}/{tool}"))
.unwrap_or_else(|| tool.clone());
vec![format!("tool: {name} · {status:?}").dim().into()]
if let Some((source_thread_id, prompt)) =
crate::dynamic_tools::parse_delegated_tool_output(
&name,
namespace.as_deref(),
&output,
)
{
cells.push(Arc::new(PrefixedWrappedHistoryCell::new(
format!("Sent by Codex from task {source_thread_id}\n{prompt}"),
"".dim(),
" ",
)));
}
}
ThreadItem::CollabAgentToolCall { tool, status, .. } => {
vec![format!("agent tool: {tool:?} · {status:?}").dim().into()]
ThreadItem::Plan { text, .. } => {
if !text.trim().is_empty() {
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
text,
cwd.as_path(),
)));
}
}
ThreadItem::SubAgentActivity {
kind, agent_path, ..
ThreadItem::Reasoning {
summary, content, ..
} => {
vec![sub_agent_activity_summary(*kind, agent_path).dim().into()]
let (header, text) =
if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
&& !content.is_empty()
{
("Reasoning".to_string(), content.join("\n\n"))
} else {
split_reasoning_summary_parts(&summary)
};
if !text.trim().is_empty() {
cells.push(Arc::new(ReasoningSummaryCell::new(
header,
text,
cwd.as_path(),
/*transcript_only*/ false,
)));
}
}
ThreadItem::ImageGeneration(item) => {
let saved = item
.saved_path
.as_ref()
.map(|path| format!(" · {}", path.as_path().display()))
.unwrap_or_default();
vec![
format!("image generation: {}{saved}", item.status)
.dim()
.into(),
]
item @ ThreadItem::CommandExecution { .. } => {
if let Some(command) = tools::CommandHistory::from_item(item) {
cells.push(Arc::new(command.into_cell()));
}
}
ThreadItem::EnteredReviewMode { review, .. } => {
vec![vec!["review started: ".dim(), review.clone().into()].into()]
item @ ThreadItem::McpToolCall { .. } => {
if let Some(call) = tools::McpHistory::from_item(item) {
cells.push(Arc::new(call.into_cell()));
}
}
ThreadItem::ExitedReviewMode { review, .. } => {
vec![vec!["review finished: ".dim(), review.clone().into()].into()]
}
ThreadItem::ContextCompaction { .. } => {
vec!["context compacted".dim().into()]
}
ThreadItem::UserMessage { .. }
| ThreadItem::AgentMessage { .. }
| ThreadItem::FunctionCallOutput { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::WebSearch(_)
| ThreadItem::ImageView { .. }
| ThreadItem::Sleep(_) => return None,
};
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
other => cells.extend(other_items::cells(other, cwd)),
}
cells
}

View File

@@ -0,0 +1,148 @@
//! Restore historical tool and notice cells without changing live application state.
//!
//! Formatting stays in the same history cells used by live events. Only completed
//! patches render as applied changes; other statuses retain their actual outcome.
use super::TranscriptCells;
use crate::app_server_approval_conversions::file_update_changes_to_display;
use crate::history_cell;
use crate::history_cell::PlainHistoryCell;
use crate::multi_agents;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::WebSearchAction;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::style::Stylize as _;
use std::sync::Arc;
pub(super) fn cells(item: ThreadItem, cwd: &AbsolutePathBuf) -> TranscriptCells {
let mut cells: TranscriptCells = Vec::new();
match item {
ThreadItem::FileChange {
id,
changes,
status,
} => match status {
PatchApplyStatus::Completed if !changes.is_empty() => {
cells.push(Arc::new(
history_cell::new_patch_event(
file_update_changes_to_display(changes),
cwd.as_path(),
)
.with_activity_id(id),
));
}
PatchApplyStatus::Completed => {}
PatchApplyStatus::Failed => {
cells.push(Arc::new(history_cell::new_patch_apply_failure(
String::new(),
)));
}
PatchApplyStatus::InProgress | PatchApplyStatus::Declined => {
let message = if status == PatchApplyStatus::Declined {
"Patch application declined"
} else {
"Patch application in progress"
};
cells.push(Arc::new(history_cell::new_info_event(
message.to_string(),
/*hint*/ None,
)));
}
},
ThreadItem::WebSearch(item) => cells.push(Arc::new(history_cell::new_web_search_call(
item.id,
item.query,
item.action.unwrap_or(WebSearchAction::Other),
))),
ThreadItem::ImageView { path, .. } => {
cells.push(Arc::new(history_cell::new_view_image_tool_call(path)))
}
ThreadItem::ImageGeneration(item)
if !matches!(item.status.as_str(), "completed" | "failed") =>
{
let status = if item.status.is_empty() {
"status unavailable"
} else {
&item.status
};
cells.push(Arc::new(history_cell::new_info_event(
format!("Image generation · {status}"),
/*hint*/ None,
)));
}
ThreadItem::ImageGeneration(item) => {
cells.push(Arc::new(history_cell::new_image_generation_call(
item.id,
&item.status,
item.revised_prompt,
item.saved_path,
)));
}
item @ ThreadItem::CollabAgentToolCall { .. } => {
if let Some(cell) = multi_agents::tool_call_history_cell(
&item,
/*cached_spawn_request*/ None,
|_| multi_agents::AgentMetadata::default(),
) {
cells.push(Arc::new(cell));
}
}
item @ ThreadItem::SubAgentActivity { .. } => {
if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) {
cells.push(Arc::new(cell));
}
}
ThreadItem::EnteredReviewMode { review, .. } => {
cells.push(Arc::new(history_cell::new_review_status_line(format!(
">> Code review started: {review} <<"
))));
}
ThreadItem::ExitedReviewMode { review, .. } => {
cells.push(Arc::new(history_cell::new_review_status_line(format!(
"<< Code review finished: {review} >>"
))));
}
ThreadItem::ContextCompaction { .. } => {
cells.push(Arc::new(history_cell::new_info_event(
"Context compacted".to_string(),
/*hint*/ None,
)));
}
// These items do not have richer history-cell presentations yet.
ThreadItem::HookPrompt { fragments, .. } => {
if !fragments.is_empty() {
cells.push(Arc::new(PlainHistoryCell::new(
fragments
.into_iter()
.map(|fragment| {
vec![
"hook prompt: ".dim(),
fragment.text.trim().to_string().into(),
]
.into()
})
.collect(),
)));
}
}
item @ ThreadItem::DynamicToolCall { .. } => {
if let Some(cell) = history_cell::DynamicToolCallCell::from_item(item) {
cells.push(Arc::new(cell));
}
}
ThreadItem::UserMessage { .. }
| ThreadItem::AgentMessage { .. }
| ThreadItem::FunctionCallOutput { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::CommandExecution { .. }
| ThreadItem::McpToolCall { .. }
| ThreadItem::Sleep(_) => {}
}
cells
}
#[cfg(test)]
#[path = "other_items_tests.rs"]
mod tests;

View File

@@ -0,0 +1,190 @@
//! Persisted tool projections retain rich detail, styles, and truthful outcomes.
use super::cells;
use crate::diff_model::FileChange;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use codex_app_server_protocol::FileUpdateChange;
use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind;
use codex_app_server_protocol::SubAgentActivityKind;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::WebSearchAction;
use codex_app_server_protocol::WebSearchItem;
use codex_utils_path_uri::LegacyAppPathString;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn completed_patch_restores_rich_diff_and_styles() {
let cwd = test_path_buf("/workspace").abs();
let mut changes = vec![
FileUpdateChange {
path: "new.rs".to_string(),
kind: PatchChangeKind::Add,
diff: "fn greet() {\n println!(\"hello\");\n}\n".to_string(),
},
FileUpdateChange {
path: "old.txt".to_string(),
kind: PatchChangeKind::Delete,
diff: "outdated\n".to_string(),
},
FileUpdateChange {
path: "src/before.rs".to_string(),
kind: PatchChangeKind::Update {
move_path: Some(PathBuf::from("src/after.rs")),
},
diff: "@@ -1 +1 @@\n-let before = 1;\n+let after = 2;\n".to_string(),
},
];
let expected = history_cell::new_patch_event(
HashMap::from([
(
PathBuf::from("new.rs"),
FileChange::Add {
content: changes[0].diff.clone(),
},
),
(
PathBuf::from("old.txt"),
FileChange::Delete {
content: changes[1].diff.clone(),
},
),
(
PathBuf::from("src/before.rs"),
FileChange::Update {
unified_diff: changes[2].diff.clone(),
move_path: Some(PathBuf::from("src/after.rs")),
},
),
]),
cwd.as_path(),
);
changes[2].diff.push_str("\n\nMoved to: src/after.rs");
let actual = cells(
ThreadItem::FileChange {
id: "patch-1".to_string(),
changes,
status: PatchApplyStatus::Completed,
},
&cwd,
);
assert_eq!(actual.len(), 1);
assert_eq!(
actual[0].transcript_hyperlink_lines(/*width*/ 80),
expected.transcript_hyperlink_lines(/*width*/ 80),
);
}
#[test]
fn unfinished_and_rejected_patches_keep_their_outcome() {
let cwd = test_path_buf("/workspace").abs();
let rendered = [
PatchApplyStatus::InProgress,
PatchApplyStatus::Declined,
PatchApplyStatus::Failed,
]
.into_iter()
.flat_map(|status| {
cells(
ThreadItem::FileChange {
id: "patch-1".to_string(),
changes: vec![FileUpdateChange {
path: "main.rs".to_string(),
kind: PatchChangeKind::Add,
diff: "fn main() {}\n".to_string(),
}],
status,
},
&cwd,
)
})
.flat_map(|cell| cell.display_lines(/*width*/ 80))
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(rendered, @"
• Patch application in progress
• Patch application declined
✘ Failed to apply patch
");
}
#[test]
fn tool_and_notice_projection_uses_normal_transcript_presentation() {
let cwd = test_path_buf("/workspace").abs();
let image = ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("A diagram of the history pages".to_string()),
result: String::new(),
transparent_background: None,
failure: None,
saved_path: None,
imagegen_request_id: None,
generation_id: None,
};
let items = vec![
ThreadItem::EnteredReviewMode {
id: "review-start".to_string(),
review: "current changes".to_string(),
},
ThreadItem::WebSearch(WebSearchItem {
id: "search-1".to_string(),
query: "fallback query".to_string(),
action: Some(WebSearchAction::FindInPage {
url: Some("https://example.com".to_string()),
pattern: Some("pagination".to_string()),
}),
results: None,
}),
ThreadItem::ImageView {
id: "view-1".to_string(),
path: LegacyAppPathString::from_string("diagram.png".to_string()),
},
ThreadItem::ImageGeneration(image.clone()),
ThreadItem::ImageGeneration(ImageGenerationItem {
status: "in_progress".into(),
..image
}),
ThreadItem::SubAgentActivity {
id: "agent-1".to_string(),
kind: SubAgentActivityKind::Completed,
agent_thread_id: "01912345-1234-7123-8123-123456789abc".to_string(),
agent_path: "/root/reviewer".to_string(),
},
ThreadItem::ExitedReviewMode {
id: "review-end".to_string(),
review: "No findings".to_string(),
},
ThreadItem::ContextCompaction {
id: "compact-1".to_string(),
},
];
let rendered = items
.into_iter()
.flat_map(|item| cells(item, &cwd))
.flat_map(|cell| cell.display_lines(/*width*/ 80))
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(rendered, @"
>> Code review started: current changes <<
• Searched for 'pagination' in https://example.com
• Viewed image diagram.png
• Generated Image:
└ A diagram of the history pages
• Image generation · in_progress
• Completed `/root/reviewer`
<< Code review finished: No findings >>
• Context compacted
");
}

View File

@@ -0,0 +1,9 @@
---
source: tui/src/thread_transcript/tools_tests.rs
expression: "snapshots.join(\"\\n\")"
---
agent tool: SpawnAgent · In progress at last update
agent tool: SendInput · Failed
agent tool: CloseAgent · Interrupted
agent tool: ResumeAgent · Failed
agent tool: Wait · Interrupted

View File

@@ -0,0 +1,34 @@
---
source: tui/src/thread_transcript/tools_tests.rs
expression: rendered
---
Command: compact
• Ran cargo check
└ output line 1
output line 2
output line 3
+9 lines (ctrl+t to view transcript)
Command: detailed
$ cargo check
output line 1
output line 2
output line 3
output line 4
output line 5
output line 6
output line 7
output line 8
output line 9
output line 10
output line 11
output line 12
✓ • 0ms
MCP: compact
• Called example.js({"title":"Inspect page mcp","code":"await cua.getState()"})
└ Full result for mcp
MCP: detailed
• Called example.js({"title":"Inspect page mcp","code":"await cua.getState()"})
└ Full result for mcp

View File

@@ -0,0 +1,52 @@
---
source: tui/src/thread_transcript/tools_tests.rs
expression: "snapshots.join(\"\\n\\n\")"
---
declined
$ cargo check
Declined
first line
indented output
last line
opaque command
$ C:\Program Files\Git\bin\bash.exe -lc "echo hi"
Completed · exit 0
first line
indented output
last line
UNC command
$ \\server\share\tool.exe -arg
Completed · exit 0
first line
indented output
last line
completed interaction
$ cargo check
Terminal interaction · Completed · exit 0
first line
indented output
last line
failed interaction
$ cargo check
Terminal interaction · Failed · exit 7
first line
indented output
last line
failed interaction without exit code
$ cargo check
Terminal interaction · Failed
first line
indented output
last line
pending interaction
$ cargo check
Terminal interaction · In progress at last update
first line
indented output
last line

View File

@@ -0,0 +1,21 @@
---
source: tui/src/thread_transcript/tools_tests.rs
assertion_line: 135
expression: rendered
---
$ cargo check
In progress at last update
output line 1
output line 2
output line 3
output line 4
output line 5
output line 6
output line 7
output line 8
output line 9
output line 10
output line 11
output line 12
mcp tool: example/js · In progress at last update
mcp tool: example/js · Completed · result unavailable

View File

@@ -0,0 +1,248 @@
//! Share tool history conversion between live handlers and persisted transcript pages.
//!
//! These values contain presentation data only. Creating historical cells never starts a task,
//! changes composer state, or leaves an animation running; pending items retain their last known
//! status. Callers own chronological grouping.
use std::time::Duration;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCell;
use crate::exec_cell::new_active_exec_command;
use crate::history_cell::McpInvocation;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::new_active_mcp_tool_call;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::CollabAgentToolCallStatus;
use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::ThreadItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::parse_command::ParsedCommand;
use ratatui::style::Stylize as _;
use ratatui::text::Line;
/// Preserve status and output when replay cannot reconstruct a rich completed tool cell.
/// Declined commands never ran, and historical terminal interactions do not carry stdin details.
/// Pending items retain their last known state without starting a running clock.
pub(crate) fn historical_tool_fallback(item: &ThreadItem) -> Option<PlainHistoryCell> {
let lines = match item {
ThreadItem::CommandExecution {
command,
source,
status,
aggregated_output,
exit_code,
..
} if matches!(
status,
CommandExecutionStatus::InProgress | CommandExecutionStatus::Declined
) || *source == CommandExecutionSource::UnifiedExecInteraction
|| replay_command_args(command).is_none() =>
{
let status_label = match status {
CommandExecutionStatus::InProgress => "In progress at last update",
CommandExecutionStatus::Completed => "Completed",
CommandExecutionStatus::Failed => "Failed",
CommandExecutionStatus::Declined => "Declined",
};
let mut status_line = if *source == CommandExecutionSource::UnifiedExecInteraction {
format!("Terminal interaction · {status_label}")
} else {
status_label.to_string()
};
if matches!(
status,
CommandExecutionStatus::Completed | CommandExecutionStatus::Failed
) && let Some(code) = exit_code
{
status_line.push_str(&format!(" · exit {code}"));
}
let mut lines: Vec<Line<'static>> = vec![
vec!["$ ".dim(), command.clone().into()].into(),
status_line.dim().into(),
];
if let Some(output) = aggregated_output {
lines.extend(
output
.lines()
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
);
}
lines
}
ThreadItem::McpToolCall {
server,
tool,
status,
result,
error,
..
} if *status == McpToolCallStatus::InProgress || (result.is_none() && error.is_none()) => {
let status = match status {
McpToolCallStatus::InProgress => "In progress at last update",
McpToolCallStatus::Completed => "Completed · result unavailable",
McpToolCallStatus::Failed => "Failed · result unavailable",
};
vec![format!("mcp tool: {server}/{tool} · {status}").dim().into()]
}
ThreadItem::CollabAgentToolCall {
tool:
tool @ (CollabAgentTool::SpawnAgent
| CollabAgentTool::SendInput
| CollabAgentTool::CloseAgent
| CollabAgentTool::ResumeAgent
| CollabAgentTool::Wait),
status,
..
} if *status != CollabAgentToolCallStatus::Completed => {
let status = match status {
CollabAgentToolCallStatus::InProgress => "In progress at last update",
CollabAgentToolCallStatus::Failed => "Failed",
CollabAgentToolCallStatus::Interrupted => "Interrupted",
CollabAgentToolCallStatus::Completed => "Completed",
};
vec![format!("agent tool: {tool:?} · {status}").dim().into()]
}
_ => return None,
};
Some(PlainHistoryCell::new(lines))
}
// The protocol joins argv with shlex. Keep noncanonical legacy commands verbatim.
fn replay_command_args(command: &str) -> Option<Vec<String>> {
let args = shlex::split(command)?;
(shlex::try_join(args.iter().map(String::as_str)).as_deref() == Ok(command)).then_some(args)
}
pub(crate) struct CommandHistory {
pub(crate) id: String,
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) source: CommandExecutionSource,
pub(crate) aggregated_output: String,
pub(crate) exit_code: i32,
pub(crate) duration: Duration,
}
impl CommandHistory {
pub(crate) fn from_item(item: ThreadItem) -> Option<Self> {
let ThreadItem::CommandExecution {
id,
command,
source,
status,
command_actions,
aggregated_output,
exit_code,
duration_ms,
..
} = item
else {
return None;
};
// Thread snapshots omit stdin, so `None` would falsely classify these as waits.
if source == CommandExecutionSource::UnifiedExecInteraction {
return None;
}
let exit_code = match status {
CommandExecutionStatus::InProgress | CommandExecutionStatus::Declined => return None,
CommandExecutionStatus::Completed => exit_code.unwrap_or_default(),
CommandExecutionStatus::Failed => {
exit_code.filter(|code| *code != 0).unwrap_or(/*default*/ 1)
}
};
Some(Self {
id,
command: replay_command_args(&command)?,
parsed: command_actions
.into_iter()
.map(codex_app_server_protocol::CommandAction::into_core)
.collect(),
source,
aggregated_output: aggregated_output.unwrap_or_default(),
exit_code,
duration: Duration::from_millis(duration_ms.unwrap_or_default().max(/*other*/ 0) as u64),
})
}
pub(crate) fn into_cell(self) -> ExecCell {
let output = CommandOutput::new(self.exit_code, self.aggregated_output);
let mut cell = new_active_exec_command(
self.id.clone(),
self.command,
self.parsed,
self.source,
/*interaction_input*/ None,
/*animations_enabled*/ false,
);
let completed = cell.complete_call(&self.id, output, self.duration);
debug_assert!(completed, "new exec cell should contain {}", self.id);
cell
}
}
pub(crate) struct McpHistory {
pub(crate) id: String,
pub(crate) invocation: McpInvocation,
pub(crate) duration: Duration,
pub(crate) result: Result<CallToolResult, String>,
}
impl McpHistory {
pub(crate) fn from_item(item: ThreadItem) -> Option<Self> {
let ThreadItem::McpToolCall {
id,
server,
tool,
status,
arguments,
result,
error,
duration_ms,
..
} = item
else {
return None;
};
if status == McpToolCallStatus::InProgress {
return None;
}
let result = match (result, error) {
(_, Some(error)) => Err(error.message),
(Some(result), None) => {
let result = *result;
Ok(CallToolResult {
content: result.content,
structured_content: result.structured_content,
is_error: Some(status == McpToolCallStatus::Failed),
meta: None,
})
}
(None, None) => return None,
};
Some(Self {
id,
invocation: McpInvocation {
server,
tool,
arguments: Some(arguments),
},
duration: Duration::from_millis(duration_ms.unwrap_or_default().max(/*other*/ 0) as u64),
result,
})
}
pub(crate) fn into_cell(self) -> McpToolCallCell {
let mut cell =
new_active_mcp_tool_call(self.id, self.invocation, /*animations_enabled*/ false);
cell.complete(self.duration, self.result);
cell
}
}
#[cfg(test)]
#[path = "tools_tests.rs"]
mod tests;

View File

@@ -0,0 +1,338 @@
use super::*;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::thread_items_to_transcript_cells;
use codex_app_server_protocol::CommandAction;
use codex_app_server_protocol::McpToolCallResult;
use codex_utils_path_uri::LegacyAppPathString;
use pretty_assertions::assert_eq;
use serde_json::json;
fn command_item(status: CommandExecutionStatus) -> ThreadItem {
ThreadItem::CommandExecution {
id: "command".to_string(),
plugin_id: None,
script_path: None,
model_context: None,
command: "cargo check".to_string(),
cwd: LegacyAppPathString::from_string("/tmp/project"),
process_id: None,
source: CommandExecutionSource::Agent,
status,
command_actions: vec![CommandAction::Unknown {
command: "cargo check".to_string(),
}],
aggregated_output: Some(
(1..=12)
.map(|line| format!("output line {line}"))
.collect::<Vec<_>>()
.join("\n"),
),
exit_code: Some(0),
duration_ms: Some(-5),
}
}
fn mcp_item(server: &str, id: &str) -> ThreadItem {
ThreadItem::McpToolCall {
id: id.to_string(),
server: server.to_string(),
tool: "js".to_string(),
status: McpToolCallStatus::Completed,
arguments: json!({"title": format!("Inspect page {id}"), "code": "await cua.getState()"}),
app_context: None,
mcp_app_resource_uri: None,
plugin_id: None,
read_only_hint: None,
mcp_app_ui: None,
result: Some(Box::new(McpToolCallResult {
content: vec![json!({"type": "text", "text": format!("Full result for {id}")})],
structured_content: None,
meta: None,
})),
error: None,
duration_ms: Some(5),
}
}
#[test]
fn completed_tools_keep_compact_and_detailed_presentations() {
let cwd = test_path_buf("/workspace").abs();
let items = [
command_item(CommandExecutionStatus::Completed),
mcp_item("example", "mcp"),
];
let cells = thread_items_to_transcript_cells(
/*thread_id*/ None,
&cwd,
items,
RawReasoningVisibility::Hidden,
/*config*/ None,
);
assert_eq!(cells.len(), 2);
let rendered = ["Command", "MCP"]
.into_iter()
.zip(cells)
.map(|(label, cell)| {
assert_eq!(cell.transcript_animation_tick(), None);
let display = cell
.display_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
let transcript = cell
.transcript_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
format!("{label}: compact\n{display}\n\n{label}: detailed\n{transcript}")
})
.collect::<Vec<_>>()
.join("\n\n");
insta::assert_snapshot!("completed_tool_presentations", rendered);
}
#[test]
fn failed_commands_keep_failure_when_exit_code_is_missing_or_zero() {
for exit in [None, Some(0)] {
let mut item = command_item(CommandExecutionStatus::Failed);
if let ThreadItem::CommandExecution { exit_code, .. } = &mut item {
*exit_code = exit;
}
let command = CommandHistory::from_item(item).unwrap();
assert_eq!((command.exit_code, command.duration), (1, Duration::ZERO));
}
}
#[test]
fn incomplete_tool_payloads_preserve_last_known_status_and_output() {
let command = command_item(CommandExecutionStatus::InProgress);
let mut mcp = mcp_item("example", "pending");
if let ThreadItem::McpToolCall { status, result, .. } = &mut mcp {
*status = McpToolCallStatus::InProgress;
*result = None;
}
let mut missing_result = mcp_item("example", "completed");
if let ThreadItem::McpToolCall { result, .. } = &mut missing_result {
*result = None;
}
let cwd = test_path_buf("/workspace").abs();
let rendered = thread_items_to_transcript_cells(
/*thread_id*/ None,
&cwd,
[command, mcp, missing_result],
RawReasoningVisibility::Hidden,
/*config*/ None,
)
.into_iter()
.flat_map(|cell| cell.display_lines(/*width*/ 80))
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("pending_tool_presentations", rendered);
}
#[test]
fn historical_command_fallbacks_preserve_status_output_and_group_boundaries() {
let cwd = test_path_buf("/workspace").abs();
let exploration = |call_id: &str| {
let mut item = command_item(CommandExecutionStatus::Completed);
if let ThreadItem::CommandExecution {
id,
command,
command_actions,
..
} = &mut item
{
*id = call_id.to_owned();
*command = "cat README.md".to_owned();
*command_actions = vec![CommandAction::Read {
command: command.clone(),
name: "README.md".to_owned(),
path: LegacyAppPathString::from_string("/workspace/README.md"),
}];
}
item
};
let mut snapshots = Vec::new();
for (source, status, recorded_exit, label) in [
(
CommandExecutionSource::Agent,
CommandExecutionStatus::Declined,
None,
"declined",
),
(
CommandExecutionSource::Agent,
CommandExecutionStatus::Completed,
Some(0),
"opaque command",
),
(
CommandExecutionSource::Agent,
CommandExecutionStatus::Completed,
Some(0),
"UNC command",
),
(
CommandExecutionSource::UnifiedExecInteraction,
CommandExecutionStatus::Completed,
Some(0),
"completed interaction",
),
(
CommandExecutionSource::UnifiedExecInteraction,
CommandExecutionStatus::Failed,
Some(7),
"failed interaction",
),
(
CommandExecutionSource::UnifiedExecInteraction,
CommandExecutionStatus::Failed,
None,
"failed interaction without exit code",
),
(
CommandExecutionSource::UnifiedExecInteraction,
CommandExecutionStatus::InProgress,
None,
"pending interaction",
),
] {
let mut item = command_item(status);
if let ThreadItem::CommandExecution {
source: item_source,
command,
aggregated_output,
exit_code,
..
} = &mut item
{
*item_source = source;
if label == "opaque command" {
*command = r#"C:\Program Files\Git\bin\bash.exe -lc "echo hi""#.to_owned();
} else if label == "UNC command" {
*command = r"\\server\share\tool.exe -arg".to_owned();
}
*aggregated_output = Some("first line\n indented output\nlast line".to_owned());
*exit_code = recorded_exit;
}
let cells = thread_items_to_transcript_cells(
/*thread_id*/ None,
&cwd,
[exploration("before"), item, exploration("after")],
RawReasoningVisibility::Hidden,
/*config*/ None,
);
assert_eq!(cells.len(), 3);
for cell in [&cells[0], &cells[2]] {
assert!(
cell.as_any()
.downcast_ref::<ExecCell>()
.unwrap()
.is_exploring_cell()
);
}
let [compact, detailed, raw] = [
cells[1].display_lines(/*width*/ 80),
cells[1].transcript_lines(/*width*/ 80),
cells[1].raw_lines(),
]
.map(|lines| {
lines
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n")
});
assert_eq!((&detailed, &raw), (&compact, &compact));
snapshots.push(format!("{label}\n{compact}"));
}
insta::assert_snapshot!(snapshots.join("\n\n"));
}
#[test]
fn agent_tool_fallbacks_preserve_status_without_duplicating_v2_activity() {
let cwd = test_path_buf("/workspace").abs();
let mut snapshots = Vec::new();
for (tool, status) in [
(
CollabAgentTool::SpawnAgent,
CollabAgentToolCallStatus::InProgress,
),
(
CollabAgentTool::SendInput,
CollabAgentToolCallStatus::Failed,
),
(
CollabAgentTool::CloseAgent,
CollabAgentToolCallStatus::Interrupted,
),
(
CollabAgentTool::ResumeAgent,
CollabAgentToolCallStatus::Failed,
),
(
CollabAgentTool::Wait,
CollabAgentToolCallStatus::Interrupted,
),
(
CollabAgentTool::SendMessage,
CollabAgentToolCallStatus::InProgress,
),
(
CollabAgentTool::FollowupTask,
CollabAgentToolCallStatus::InProgress,
),
(
CollabAgentTool::InterruptAgent,
CollabAgentToolCallStatus::InProgress,
),
(
CollabAgentTool::ListAgents,
CollabAgentToolCallStatus::InProgress,
),
] {
let visible = matches!(
tool,
CollabAgentTool::SpawnAgent
| CollabAgentTool::SendInput
| CollabAgentTool::CloseAgent
| CollabAgentTool::ResumeAgent
| CollabAgentTool::Wait
);
let item = ThreadItem::CollabAgentToolCall {
id: "pending-agent-call".to_string(),
tool,
status,
sender_thread_id: "00000000-0000-0000-0000-000000000001".to_string(),
receiver_thread_ids: vec!["00000000-0000-0000-0000-000000000002".to_string()],
prompt: Some("Inspect the parser".to_string()),
model: None,
reasoning_effort: None,
agents_states: Default::default(),
};
let cells = thread_items_to_transcript_cells(
/*thread_id*/ None,
&cwd,
[item],
RawReasoningVisibility::Hidden,
/*config*/ None,
);
assert_eq!(cells.len(), usize::from(visible));
for cell in cells {
assert_eq!(cell.transcript_animation_tick(), None);
let text = cell
.transcript_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
snapshots.push(text);
}
}
insta::assert_snapshot!(snapshots.join("\n"));
}