Align persisted TUI activity groups and reasoning with live output (#46711)

## Why

Persisted transcripts rendered tool calls individually and displayed reasoning differently from live replay. Enabling raw reasoning also replaced the summary instead of retaining it.

## What changed

- Group computer activity and compatible exploration commands across intervening reasoning, retaining details for the expanded transcript. Respect visible content, command failure boundaries, and turn boundaries.
- Separate paginated history at every turn boundary before filtering hidden items, while adding completion metadata only for successful turns.
- Keep persisted reasoning out of the compact display, preserve hyperlinks in detailed rendering, and append enabled raw reasoning to the summary. Preserve standalone reasoning Markdown in transcript exports.
- Add helpers to rejoin activity groups and attach reasoning across page boundaries using persisted item identities and turn adjacency.

## Testing

Add regression coverage for reasoning replay parity, computer groups split across history pages, turn boundaries, and grouped terminal input. Update export and raw-reasoning tests to verify retained summaries and Markdown.

GitOrigin-RevId: a716db571440f891cc2c6894ae93fbe0e149aed4
This commit is contained in:
Eric Traut
2026-09-19 20:27:28 +00:00
committed by copyberry
parent 4abcb8d1da
commit f43faf132d
22 changed files with 896 additions and 57 deletions
+5 -5
View File
@@ -1,4 +1,4 @@
//! Group older history items at successful turn boundaries before projecting transcript cells.
//! Separate older history at every turn boundary; only successful turns gain completion metadata.
//! A page splitting a turn only adds completion metadata when it contains that turn's last item.
use codex_app_server_protocol::ThreadItem;
@@ -10,17 +10,17 @@ pub(super) fn group_completed_turn_items(
items: Vec<ThreadItem>,
turns: &[Turn],
) -> Vec<(Vec<ThreadItem>, Option<&Turn>)> {
let completed_boundaries: HashMap<_, _> = turns
let boundaries: HashMap<_, _> = turns
.iter()
.filter(|turn| turn.status == TurnStatus::Completed)
.filter_map(|turn| turn.items.last().map(|item| (item.id(), turn)))
.collect();
let mut groups = Vec::new();
let mut pending = Vec::new();
for item in items {
let completed_turn = completed_boundaries.get(item.id()).copied();
let boundary = boundaries.get(item.id()).copied();
pending.push(item);
if completed_turn.is_some() {
if let Some(turn) = boundary {
let completed_turn = (turn.status == TurnStatus::Completed).then_some(turn);
groups.push((std::mem::take(&mut pending), completed_turn));
}
}
@@ -58,7 +58,7 @@ fn multiple_turns_keep_item_groups_and_completion_order() {
}
#[test]
fn unsuccessful_and_running_turns_do_not_create_completion_boundaries() {
fn unsuccessful_and_running_turns_separate_groups_without_completion_metadata() {
let turns = vec![
turn("failed", TurnStatus::Failed, &["a"]),
turn("interrupted", TurnStatus::Interrupted, &["b"]),
@@ -69,7 +69,10 @@ fn unsuccessful_and_running_turns_do_not_create_completion_boundaries() {
.flat_map(|turn| turn.items.clone())
.collect::<Vec<_>>();
assert_eq!(
group_completed_turn_items(items.clone(), &turns),
vec![(items, None)],
group_completed_turn_items(items, &turns),
turns
.iter()
.map(|turn| (turn.items.clone(), None))
.collect::<Vec<_>>(),
);
}
+3 -3
View File
@@ -88,7 +88,7 @@ impl App {
store.turns.clone(),
)
};
let mut items = app_server
let items = app_server
.apply_older_history_page(thread_id, cursor, page, &mut turns)
.await?;
let mut hidden_item_ids = HashSet::new();
@@ -185,9 +185,9 @@ impl App {
}
}
}
items.retain(|item| !hidden_item_ids.contains(item.id()));
let mut cells = Vec::new();
for (items, completed_turn) in completion::group_completed_turn_items(items, &turns) {
for (mut items, completed_turn) in completion::group_completed_turn_items(items, &turns) {
items.retain(|item| !hidden_item_ids.contains(item.id()));
cells.extend(thread_items_to_transcript_cells(
Some(thread_id),
&cwd,
@@ -1,9 +1,14 @@
---
source: tui/src/app/transcript_export_tests.rs
assertion_line: 227
expression: "render_markdown_transcript(&cells).expect(\"exported transcript\")"
---
# Codex conversation
## Reasoning
Inspect **the saved result**.
## Activity
Opened https://example.com/long/path?query=full#details
@@ -237,6 +237,8 @@ fn render_markdown_transcript(cells: &[Arc<dyn HistoryCell>]) -> Result<String,
}
lines.extend(image_labels.into_iter().map(Into::into));
lines
} else if let Some(reasoning) = cell.as_any().downcast_ref::<ReasoningSummaryCell>() {
raw_lines_from_source(reasoning.markdown_source().trim())
} else {
cell.raw_lines()
};
@@ -201,6 +201,10 @@ fn transcript_file_resolves_relative_paths_and_refuses_to_overwrite() {
#[test]
fn persisted_web_and_image_activity_preserves_full_details() {
let items = [
serde_json::json!({
"type": "reasoning", "id": "reasoning",
"summary": ["Inspect **the saved result**."], "content": ["Raw details stay hidden."]
}),
serde_json::json!({
"type": "webSearch", "id": "open", "query": "",
"action": {"type": "openPage", "url": "https://example.com/long/path?query=full#details"}
+2 -1
View File
@@ -349,7 +349,8 @@ impl ChatWidget {
.or(self.reasoning_header.take());
if !self.reasoning_summary_parts.is_empty() {
let reasoning_parts = std::mem::take(&mut self.reasoning_summary_parts);
let cell = history_cell::new_reasoning_summary_block(reasoning_parts, &self.config.cwd);
let cell: Box<dyn HistoryCell> =
history_cell::new_reasoning_summary_block(reasoning_parts, &self.config.cwd);
let result = match self.transcript.active_cell.as_mut() {
Some(active) => active.append_reasoning(cell),
None => Err(cell),
+2
View File
@@ -252,6 +252,8 @@ mod copy_export_picker_tests;
mod exec_flow;
mod goal_menu;
mod goal_validation;
#[path = "tests/group_projection_tests.rs"]
mod group_projection_tests;
mod guardian;
pub(crate) mod helpers;
mod history_replay;
@@ -0,0 +1,79 @@
//! Persisted reasoning matches live replay in compact and detailed presentations.
use super::*;
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::thread_items_to_transcript_cells;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn snapshot_formatter_reasoning_matches_legacy_and_detailed_replay() {
let mut snapshots = Vec::new();
for visibility in [
RawReasoningVisibility::Hidden,
RawReasoningVisibility::Visible,
] {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
drain_insert_history(&mut rx);
chat.config.show_raw_agent_reasoning = visibility == RawReasoningVisibility::Visible;
let item = AppServerThreadItem::Reasoning {
id: "reasoning".to_string(),
summary: vec![
"**Plan**\n\nRead [src/main.rs](src/main.rs:3).".to_string(),
"**Checking tests**\n\n<!-- -->".to_string(),
],
content: vec!["Raw detail.".to_string()],
};
let projected = thread_items_to_transcript_cells(
chat.thread_id,
&chat.config.cwd,
[item.clone()],
visibility,
Some(&chat.config),
);
chat.replay_thread_item(item, "turn".to_string(), ReplayKind::ThreadSnapshot);
let replayed: Vec<Box<dyn HistoryCell>> = std::iter::from_fn(|| rx.try_recv().ok())
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => Some(cell),
_ => None,
})
.collect();
assert_eq!((projected.len(), replayed.len()), (1, 1));
assert!(
projected[0]
.display_hyperlink_lines(/*width*/ 80)
.is_empty()
);
assert!(replayed[0].display_hyperlink_lines(/*width*/ 80).is_empty());
for width in [28, 80] {
assert_eq!(
projected[0].transcript_hyperlink_lines(width),
replayed[0].transcript_hyperlink_lines(width)
);
}
snapshots.push(format!(
"{visibility:?}\nlegacy: {:?}\ndetailed:\n{}",
projected[0].display_lines(/*width*/ 80),
lines_to_single_string(&projected[0].transcript_lines(/*width*/ 80)),
));
}
insta::assert_snapshot!(snapshots.join("\n"));
}
#[test]
fn raw_reasoning_keeps_its_own_heading() {
let projected = thread_items_to_transcript_cells(
/*thread_id*/ None,
&codex_utils_absolute_path::AbsolutePathBuf::current_dir().unwrap(),
[AppServerThreadItem::Reasoning {
id: "raw".into(),
summary: Vec::new(),
content: vec!["**Raw investigation**\nKeep this heading and its details.".into()],
}],
RawReasoningVisibility::Visible,
/*config*/ None,
);
insta::assert_snapshot!(lines_to_single_string(&projected[0].transcript_lines(/*width*/ 80)), @"
• Raw investigation
Keep this heading and its details.
");
}
@@ -0,0 +1,15 @@
---
source: tui/src/chatwidget/tests/group_projection_tests.rs
expression: "snapshots.join(\"\\n\")"
---
Hidden
legacy: []
detailed:
• Read src/main.rs (src/main.rs:3).
Visible
legacy: []
detailed:
• Read src/main.rs (src/main.rs:3).
Raw detail.
+14
View File
@@ -115,6 +115,20 @@ impl ExecCell {
}
}
/// Historical grouping uses the same compatibility and failure boundary as live calls.
pub(crate) fn append_completed(&mut self, mut newer: Self) -> Result<(), Self> {
if !self.is_exploring_cell() || self.should_flush() || !newer.is_exploring_cell() {
return Err(newer);
}
newer.group.details.prepend(
std::mem::take(&mut self.group.details),
self.group.calls.len(),
);
self.group.details = newer.group.details;
self.group.calls.append(&mut newer.group.calls);
Ok(())
}
/// Preserve live clocks and pending output when a validated older page extends exploration.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn prepend(&mut self, older: Self) {
@@ -0,0 +1,29 @@
---
source: tui/src/exec_cell/transcript_tests.rs
assertion_line: 73
expression: "format!(\"Rich\\n{rich}\\n\\nRaw\\n{}\", raw.join(\"\\n\"))"
---
Rich
$ cat first.txt
first output
✓ • 5ms
• Inspecting the next file
↳ Interacted with background terminal · cat first.txt
└ continue
$ cat second.txt
second output
✓ • 5ms
Raw
$ cat first.txt
first output
✓ • 5ms
Interacted with background terminal: cat first.txt
continue
$ cat second.txt
second output
✓ • 5ms
+4
View File
@@ -69,3 +69,7 @@ impl ExecCell {
lines
}
}
#[cfg(test)]
#[path = "transcript_tests.rs"]
mod tests;
@@ -0,0 +1,90 @@
//! Grouped command details retain terminal input in raw output without exposing hidden reasoning.
use super::*;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::model::ExecCall;
use crate::history_cell::HistoryCell;
use crate::history_cell::new_reasoning_summary_block;
use crate::history_cell::new_unified_exec_interaction;
use codex_app_server_protocol::CommandExecutionSource;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
fn completed_read(name: &str, output: &str) -> ExecCell {
let command = vec!["cat".to_owned(), name.to_owned()];
let parsed = codex_shell_command::parse_command::parse_command(&command);
ExecCell::new(
ExecCall {
call_id: name.to_owned(),
command,
parsed,
output: Some(CommandOutput::new(/*exit_code*/ 0, output.to_owned())),
source: CommandExecutionSource::UnifiedExecStartup,
start_time: None,
duration: Some(Duration::from_millis(/*millis*/ 5)),
interaction_input: None,
},
/*animations_enabled*/ false,
)
}
#[test]
fn raw_grouped_history_retains_terminal_input_and_omits_reasoning() {
let mut group = completed_read("first.txt", "first output");
group
.group
.push_detail(Arc::from(new_reasoning_summary_block(
vec!["Inspecting the next file".to_owned()],
Path::new("."),
) as Box<dyn HistoryCell>));
group
.group
.push_detail(Arc::new(new_unified_exec_interaction(
Some("cat first.txt".to_owned()),
"continue\n".to_owned(),
)));
group
.append_completed(completed_read("second.txt", "second output"))
.expect("adjacent reads stay grouped");
let raw = group
.raw_lines()
.iter()
.map(Line::to_string)
.collect::<Vec<_>>();
let rich = group
.transcript_lines(/*width*/ 80)
.iter()
.map(Line::to_string)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(format!("Rich\n{rich}\n\nRaw\n{}", raw.join("\n")));
let mut earlier = completed_read("earlier.txt", "earlier output");
earlier
.group
.push_detail(Arc::from(new_reasoning_summary_block(
vec!["Inspect the first file".to_owned()],
Path::new("."),
) as Box<dyn HistoryCell>));
earlier
.group
.push_detail(Arc::new(new_unified_exec_interaction(
Some("cat earlier.txt".to_owned()),
"next\n".to_owned(),
)));
let mut expected_raw = earlier.raw_lines();
expected_raw.push(Line::from(""));
expected_raw.extend(group.raw_lines());
let mut expected_rich = earlier.transcript_hyperlink_lines(/*width*/ 80);
expected_rich.push(HyperlinkLine::from(""));
expected_rich.extend(group.transcript_hyperlink_lines(/*width*/ 80));
group.prepend(earlier);
assert_eq!(group.raw_lines(), expected_raw);
assert_eq!(
group.transcript_hyperlink_lines(/*width*/ 80),
expected_rich
);
}
+4 -4
View File
@@ -270,10 +270,10 @@ impl McpToolCallCell {
continue;
};
for segment in text.lines() {
let line = Line::from(segment.to_owned().dim());
if mode == McpToolCallRenderMode::Display {
preview.push_line(line);
preview.push_line(Line::from(segment.dim()));
} else {
let line = Line::from(segment.to_owned().dim());
lines.extend(adaptive_wrap_hyperlink_lines(
&[HyperlinkLine::new(line)],
options.clone(),
@@ -285,10 +285,10 @@ impl McpToolCallCell {
Some(Err(error)) => {
let text = format!("Error: {error}");
for segment in text.lines() {
let line = Line::from(segment.to_owned().dim());
if mode == McpToolCallRenderMode::Display {
preview.push_line(line);
preview.push_line(Line::from(segment.dim()));
} else {
let line = Line::from(segment.to_owned().dim());
lines.extend(adaptive_wrap_hyperlink_lines(
&[HyperlinkLine::new(line)],
options.clone(),
+40 -8
View File
@@ -318,9 +318,30 @@ pub(crate) struct ReasoningSummaryCell {
/// Session cwd used to render local file links inside the reasoning body.
cwd: PathBuf,
transcript_only: bool,
/// Persisted identity verifies page folds without comparing rendered reasoning text.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
source_item_id: Option<String>,
}
impl ReasoningSummaryCell {
pub(crate) fn markdown_source(&self) -> &str {
&self.content
}
pub(crate) fn set_source_item_id(&mut self, id: String) {
self.source_item_id = Some(id);
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn source_item_id(&self) -> Option<&str> {
self.source_item_id.as_deref()
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn is_transcript_only(&self) -> bool {
self.transcript_only
}
/// Create a reasoning summary cell that will render local file links relative to the session
/// cwd active when the summary was recorded.
pub(crate) fn new(header: String, content: String, cwd: &Path, transcript_only: bool) -> Self {
@@ -329,31 +350,34 @@ impl ReasoningSummaryCell {
content,
cwd: cwd.to_path_buf(),
transcript_only,
source_item_id: None,
}
}
fn lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
fn lines(&self, width: u16) -> Vec<HyperlinkLine> {
let lines = crate::markdown_render::render_markdown_lines_with_width_and_cwd(
&self.content,
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2),
Some(self.cwd.as_path()),
&mut lines,
);
let summary_style = Style::default().dim().italic();
let summary_lines = lines
.into_iter()
.map(|mut line| {
line.spans = line
line.line.spans = line
.line
.spans
.into_iter()
.map(|span| span.patch_style(summary_style))
.collect();
if let Some(source) = &mut line.source {
source.span_style = source.span_style.patch(summary_style);
}
line
})
.collect::<Vec<_>>();
adaptive_wrap_lines(
crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(
&summary_lines,
RtOptions::new(width as usize)
.initial_indent("• ".dim().into())
@@ -364,6 +388,10 @@ impl ReasoningSummaryCell {
impl HistoryCell for ReasoningSummaryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.display_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
if self.transcript_only {
Vec::new()
} else {
@@ -372,6 +400,10 @@ impl HistoryCell for ReasoningSummaryCell {
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.transcript_hyperlink_lines(width))
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.lines(width)
}
@@ -379,7 +411,7 @@ impl HistoryCell for ReasoningSummaryCell {
if self.transcript_only {
Vec::new()
} else {
raw_lines_from_source(self.content.trim())
raw_lines_from_source(self.markdown_source().trim())
}
}
}
@@ -686,7 +718,7 @@ pub(crate) fn new_spoken_user_prompt(message: String) -> UserHistoryCell {
pub(crate) fn new_reasoning_summary_block(
reasoning_parts: Vec<String>,
cwd: &Path,
) -> Box<dyn HistoryCell> {
) -> Box<ReasoningSummaryCell> {
let (header, content) = split_reasoning_summary_parts(&reasoning_parts);
Box::new(ReasoningSummaryCell::new(
header, content, cwd, /*transcript_only*/ true,
+2 -3
View File
@@ -6756,7 +6756,7 @@ session_picker_view = "dense"
}
#[test]
fn thread_to_transcript_cells_shows_raw_reasoning_over_summary_when_enabled() {
fn thread_to_transcript_cells_retains_summary_and_raw_reasoning_when_enabled() {
use crate::thread_transcript::thread_to_transcript_cells;
let thread_id = ThreadId::new();
@@ -6819,8 +6819,7 @@ session_picker_view = "dense"
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("raw reasoning content"));
assert!(!rendered.contains("public summary"));
assert_eq!(rendered, "• public summary\n \n raw reasoning content");
}
#[tokio::test]
+159 -30
View File
@@ -1,4 +1,6 @@
//! Project individual persisted items into rich history cells without changing live state.
//! Project persisted items into the same rich cells as completed live output.
//! Tool grouping spans hidden reasoning but stops at visible content and turn boundaries.
//! Only one computer or exploration group can be pending at a time.
use std::sync::Arc;
@@ -22,11 +24,61 @@ use codex_protocol::items::UserMessageItem;
use codex_utils_absolute_path::AbsolutePathBuf;
use ratatui::style::Stylize as _;
mod activity_pages;
mod computer_groups;
mod exploration_groups;
mod other_items;
pub(crate) mod tools;
#[allow(
unused_imports,
reason = "Used by later layers of the TUI refresh stack."
)]
pub(crate) use activity_pages::fold_trailing_activity_details;
#[allow(
unused_imports,
reason = "Used by later layers of the TUI refresh stack."
)]
pub(crate) use activity_pages::is_hidden_activity_detail;
#[allow(
unused_imports,
reason = "Used by later layers of the TUI refresh stack."
)]
pub(crate) use computer_groups::join_computer_groups;
#[allow(
unused_imports,
reason = "Used by later layers of the TUI refresh stack."
)]
pub(crate) use computer_groups::older_computer_group;
#[allow(
unused_imports,
reason = "Used by later layers of the TUI refresh stack."
)]
pub(crate) use exploration_groups::join_exploration_groups;
#[allow(
unused_imports,
reason = "Used by later layers of the TUI refresh stack."
)]
pub(crate) use exploration_groups::older_exploration_group;
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
enum PendingActivity {
Computer(crate::history_cell::ComputerActivityCell),
Exploration(crate::exec_cell::ExecCell),
}
impl PendingActivity {
fn flush(pending: &mut Option<Self>, cells: &mut TranscriptCells) {
let cell: Arc<dyn HistoryCell> = match pending.take() {
Some(Self::Computer(group)) => Arc::new(group),
Some(Self::Exploration(group)) => Arc::new(group),
None => return,
};
cells.push(cell);
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RawReasoningVisibility {
Hidden,
@@ -68,13 +120,19 @@ pub(crate) fn thread_to_transcript_cells(
) -> TranscriptCells {
let cwd = thread.cwd;
let thread_id = ThreadId::from_string(&thread.id).ok();
let mut cells = thread_items_to_transcript_cells(
thread_id,
&cwd,
thread.turns.into_iter().flat_map(|turn| turn.items),
raw_reasoning_visibility,
config,
);
let mut cells = thread
.turns
.into_iter()
.flat_map(|turn| {
thread_items_to_transcript_cells(
thread_id,
&cwd,
turn.items,
raw_reasoning_visibility,
config,
)
})
.collect::<TranscriptCells>();
if cells.is_empty() {
cells.push(Arc::new(PlainHistoryCell::new(vec![
"No transcript content available".italic().dim().into(),
@@ -94,18 +152,89 @@ pub(crate) fn thread_items_to_transcript_cells(
thread_id.and_then(|thread_id| InlineVisualizationContext::from_config(config, thread_id))
});
let mut cells: TranscriptCells = Vec::new();
let mut pending = None;
for item in items {
if let Some(cell) = tools::historical_tool_fallback(&item) {
cells.push(Arc::new(cell));
} else {
cells.extend(item_to_cells(
if matches!(item, ThreadItem::Reasoning { .. })
&& let Some(group) = &mut pending
{
for cell in item_to_cells(
item,
cwd,
raw_reasoning_visibility,
inline_visualization_context.clone(),
));
) {
match group {
PendingActivity::Computer(group) => group.group.push_detail(cell),
PendingActivity::Exploration(group) => group.group.push_detail(cell),
}
}
continue;
}
if let Some(cell) = tools::historical_tool_fallback(&item) {
PendingActivity::flush(&mut pending, &mut cells);
cells.push(Arc::new(cell));
continue;
}
match item {
item @ ThreadItem::McpToolCall { .. } => {
let Some(call) = tools::McpHistory::from_item(item) else {
PendingActivity::flush(&mut pending, &mut cells);
continue;
};
if call.invocation.is_computer_activity() {
match &mut pending {
Some(PendingActivity::Computer(group)) => {
computer_groups::append(group, call);
}
Some(PendingActivity::Exploration(_)) | None => {
PendingActivity::flush(&mut pending, &mut cells);
let mut group = crate::history_cell::ComputerActivityCell::default();
computer_groups::append(&mut group, call);
pending = Some(PendingActivity::Computer(group));
}
}
} else {
PendingActivity::flush(&mut pending, &mut cells);
let cell = call.into_cell();
cells.push(Arc::new(cell));
}
}
item @ ThreadItem::CommandExecution { .. } => {
if matches!(pending, Some(PendingActivity::Computer(_))) {
PendingActivity::flush(&mut pending, &mut cells);
}
if let Some(command) = tools::CommandHistory::from_item(item) {
let newer = command.into_cell();
if let Some(PendingActivity::Exploration(group)) = &mut pending {
if let Err(newer) = group.append_completed(newer) {
PendingActivity::flush(&mut pending, &mut cells);
pending = Some(PendingActivity::Exploration(newer));
}
} else {
pending = Some(PendingActivity::Exploration(newer));
}
if let Some(PendingActivity::Exploration(group)) = &pending
&& group.should_flush()
{
PendingActivity::flush(&mut pending, &mut cells);
}
}
}
item => {
let projected = item_to_cells(
item,
cwd,
raw_reasoning_visibility,
inline_visualization_context.clone(),
);
if !projected.is_empty() {
PendingActivity::flush(&mut pending, &mut cells);
cells.extend(projected);
}
}
}
}
PendingActivity::flush(&mut pending, &mut cells);
cells
}
@@ -196,23 +325,28 @@ fn item_to_cells(
}
}
ThreadItem::Reasoning {
summary, content, ..
id,
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)
};
let (header, mut text) = split_reasoning_summary_parts(&summary);
if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
&& !content.is_empty()
{
if !text.is_empty() {
text.push_str("\n\n");
}
text.push_str(&content.join("\n\n"));
}
if !text.trim().is_empty() {
cells.push(Arc::new(ReasoningSummaryCell::new(
let mut cell = ReasoningSummaryCell::new(
header,
text,
cwd.as_path(),
/*transcript_only*/ false,
)));
/*transcript_only*/ true,
);
cell.set_source_item_id(id);
cells.push(Arc::new(cell));
}
}
item @ ThreadItem::CommandExecution { .. } => {
@@ -220,11 +354,6 @@ fn item_to_cells(
cells.push(Arc::new(command.into_cell()));
}
}
item @ ThreadItem::McpToolCall { .. } => {
if let Some(call) = tools::McpHistory::from_item(item) {
cells.push(Arc::new(call.into_cell()));
}
}
other => cells.extend(other_items::cells(other, cwd)),
}
cells
@@ -0,0 +1,65 @@
//! Attach transparent page-boundary details to a freshly rebuilt activity group.
use crate::exec_cell::ExecCell;
use crate::history_cell::ComputerActivityCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::ReasoningSummaryCell;
use std::sync::Arc;
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn is_hidden_activity_detail(cell: &Arc<dyn HistoryCell>) -> bool {
cell.as_any()
.downcast_ref::<ReasoningSummaryCell>()
.is_some_and(ReasoningSummaryCell::is_transcript_only)
}
/// Fold standalone reasoning using exact persisted identities from one turn.
///
/// An older page can arrive after the final reasoning page even with no newer group or live tail.
/// Rebuild only known completed calls and share their immutable details; never infer adjacency
/// from identical reasoning text or attach a detail from the next turn.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn fold_trailing_activity_details(
older: &Arc<dyn HistoryCell>,
details: &[Arc<dyn HistoryCell>],
turns: &[codex_app_server_protocol::Turn],
) -> Option<Arc<dyn HistoryCell>> {
let (mut ids, retained) =
if let Some(group) = older.as_any().downcast_ref::<ComputerActivityCell>() {
(group.call_ids().collect::<Vec<_>>(), &group.group.details)
} else {
let group = older.as_any().downcast_ref::<ExecCell>()?;
if !group.is_exploring_cell() || group.should_flush() {
return None;
}
(
group
.iter_calls()
.map(|call| call.call_id.as_str())
.collect::<Vec<_>>(),
&group.group.details,
)
};
let count = ids.len();
for detail in details {
let detail = detail.as_any().downcast_ref::<ReasoningSummaryCell>()?;
if !detail.is_transcript_only() {
return None;
}
ids.push(detail.source_item_id()?);
}
let items = super::computer_groups::adjacent_activity_items(&ids, turns)?;
let mut retained = retained.clone();
for detail in details {
retained.push(count, Arc::clone(detail));
}
if older.as_any().is::<ComputerActivityCell>() {
let mut group = super::computer_groups::completed_group(items)?;
group.group.details = retained;
Some(Arc::new(group))
} else {
let mut group = super::exploration_groups::completed_group(items)?;
group.group.details = retained;
Some(Arc::new(group))
}
}
@@ -0,0 +1,111 @@
//! Assemble computer activity across hidden reasoning and repair history-page group boundaries.
use super::tools::McpHistory;
use crate::history_cell::ComputerActivityCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use std::sync::Arc;
pub(super) fn append(group: &mut ComputerActivityCell, call: McpHistory) {
let cell = McpToolCallCell::new(call.id, call.invocation, /*animations_enabled*/ false);
group.complete(cell, call.duration, call.result);
}
/// Rebuild only an adjacent computer group, using its persisted IDs to verify the turn boundary.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn join_computer_groups(
older: &Arc<dyn HistoryCell>,
newer: &Arc<dyn HistoryCell>,
turns: &[Turn],
) -> Option<Arc<dyn HistoryCell>> {
let older = older.as_any().downcast_ref::<ComputerActivityCell>()?;
let newer = newer.as_any().downcast_ref::<ComputerActivityCell>()?;
let mut group = completed_group(adjacent_items(older, newer, turns)?)?;
group.group.details = newer.group.details.clone();
group
.group
.details
.prepend(older.group.details.clone(), older.call_ids().count());
Some(Arc::new(group))
}
/// Restore only the older half; the caller retains the live half and its pending calls/timers.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn older_computer_group(
older: &dyn HistoryCell,
newer: &ComputerActivityCell,
turns: &[Turn],
) -> Option<ComputerActivityCell> {
let older = older.as_any().downcast_ref::<ComputerActivityCell>()?;
let items = adjacent_items(older, newer, turns)?;
let older_items = items
.iter()
.filter(|item| !matches!(item, ThreadItem::Reasoning { .. }))
.take(older.call_ids().count())
.cloned()
.collect::<Vec<_>>();
let mut group = completed_group(&older_items)?;
group.group.details = older.group.details.clone();
Some(group)
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn adjacent_items<'a>(
older: &ComputerActivityCell,
newer: &ComputerActivityCell,
turns: &'a [Turn],
) -> Option<&'a [ThreadItem]> {
let ids = older.call_ids().chain(newer.call_ids()).collect::<Vec<_>>();
adjacent_activity_items(&ids, turns)
}
/// Validate source adjacency without mistaking invisible reasoning for an activity boundary.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(super) fn adjacent_activity_items<'a>(
ids: &[&str],
turns: &'a [Turn],
) -> Option<&'a [ThreadItem]> {
let first = *ids.first()?;
let (turn, start) = turns.iter().find_map(|turn| {
turn.items
.iter()
.position(|item| item.id() == first)
.map(|start| (turn, start))
})?;
let mut matched = 0;
for (offset, item) in turn.items[start..].iter().enumerate() {
if item.id() != ids[matched] {
if matches!(item, ThreadItem::Reasoning { .. }) {
continue;
}
return None;
}
matched += 1;
if matched == ids.len() {
return Some(&turn.items[start..=start + offset]);
}
}
None
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(super) fn completed_group(items: &[ThreadItem]) -> Option<ComputerActivityCell> {
let mut group = None;
for item in items {
if matches!(item, ThreadItem::Reasoning { .. }) {
continue;
}
let call = McpHistory::from_item(item.clone())?;
if !call.invocation.is_computer_activity() {
return None;
}
append(group.get_or_insert_default(), call);
}
group
}
#[cfg(test)]
#[path = "computer_groups_tests.rs"]
mod tests;
@@ -0,0 +1,173 @@
//! Page boundaries must not change the compact or expanded rendering of computer activity.
use super::*;
use crate::thread_transcript::RawReasoningVisibility;
use crate::thread_transcript::TranscriptCells;
use crate::thread_transcript::thread_items_to_transcript_cells;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStatus;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
fn computer(id: &str) -> ThreadItem {
serde_json::from_value(serde_json::json!({
"type": "mcpToolCall", "id": id, "server": "cua_repl", "tool": "js",
"status": "completed", "arguments": {"title": format!("Inspect {id}"), "code": "await cua.getState()"},
"appContext": null, "pluginId": null, "readOnlyHint": null,
"result": {"content": [{"type": "text", "text": format!("Result {id}")}], "structuredContent": null},
"error": null, "durationMs": 10
})).unwrap()
}
fn turn(id: &str, items: Vec<ThreadItem>) -> Turn {
Turn {
id: id.to_string(),
items,
items_view: TurnItemsView::Full,
status: TurnStatus::Completed,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}
}
fn project(items: &[ThreadItem]) -> TranscriptCells {
thread_items_to_transcript_cells(
/*thread_id*/ None,
&AbsolutePathBuf::current_dir().unwrap(),
items.to_vec(),
RawReasoningVisibility::Hidden,
/*config*/ None,
)
}
#[test]
fn every_page_split_retains_the_same_rich_computer_group() {
let turns = vec![turn(
"turn",
vec![
computer("first"),
ThreadItem::Reasoning {
id: "reasoning-first".to_owned(),
summary: vec!["Inspect the second screen".to_owned()],
content: Vec::new(),
},
computer("second"),
computer("third"),
ThreadItem::Reasoning {
id: "reasoning-second".to_owned(),
summary: vec!["Confirm the final screen".to_owned()],
content: Vec::new(),
},
computer("last"),
],
)];
let items = &turns[0].items;
let expected = project(items);
for split in 1..items.len() {
let older = project(&items[..split]);
let newer = project(&items[split..]);
let detail_count = newer
.iter()
.take_while(|cell| {
crate::thread_transcript::activity_pages::is_hidden_activity_detail(cell)
})
.count();
let older_group = if detail_count > 0 {
crate::thread_transcript::activity_pages::fold_trailing_activity_details(
&older[0],
&newer[..detail_count],
&turns,
)
.unwrap()
} else {
Arc::clone(&older[0])
};
let joined = join_computer_groups(&older_group, &newer[detail_count], &turns).unwrap();
assert_eq!(
joined.display_hyperlink_lines(/*width*/ 30),
expected[0].display_hyperlink_lines(/*width*/ 30)
);
assert_eq!(
joined.transcript_hyperlink_lines(/*width*/ 30),
expected[0].transcript_hyperlink_lines(/*width*/ 30)
);
assert_eq!(joined.raw_lines(), expected[0].raw_lines());
}
}
#[test]
fn successive_single_item_pages_reunite_without_duplicating_calls() {
let turns = vec![turn(
"turn",
(0..6).map(|i| computer(&i.to_string())).collect(),
)];
let items = &turns[0].items;
let mut joined = project(&items[5..]).remove(/*index*/ 0);
for item in items[..5].iter().rev() {
let older = project(std::slice::from_ref(item));
joined = join_computer_groups(&older[0], &joined, &turns).unwrap();
}
let expected = project(items);
assert_eq!(
joined.transcript_lines(/*width*/ 80),
expected[0].transcript_lines(/*width*/ 80)
);
}
#[test]
fn joins_respect_actual_turns_and_intervening_items() {
let first = computer("first");
let last = computer("last");
let older = project(std::slice::from_ref(&first));
let newer = project(std::slice::from_ref(&last));
let turns = vec![
turn("first-turn", vec![first.clone()]),
turn("last-turn", vec![last.clone()]),
];
assert!(join_computer_groups(&older[0], &newer[0], &turns).is_none());
let grouped = project(&[
first.clone(),
ThreadItem::Sleep(codex_app_server_protocol::SleepItem {
id: "sleep".into(),
duration_ms: 10,
}),
last.clone(),
]);
let adjacent = project(&[first.clone(), last.clone()]);
assert_eq!(grouped.len(), 1);
assert_eq!(
grouped[0].transcript_lines(/*width*/ 80),
adjacent[0].transcript_lines(/*width*/ 80)
);
let mut turns = vec![turn(
"turn",
vec![
first,
ThreadItem::Reasoning {
id: "reasoning".to_string(),
summary: Vec::new(),
content: Vec::new(),
},
last,
],
)];
let joined = join_computer_groups(&older[0], &newer[0], &turns)
.expect("hidden reasoning retains the adjacent computer group");
let expected = project(&turns[0].items);
assert_eq!(
joined.transcript_lines(/*width*/ 80),
expected[0].transcript_lines(/*width*/ 80),
);
turns[0].items[1] = ThreadItem::AgentMessage {
id: "message".to_string(),
text: "Visible boundary".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
};
assert!(join_computer_groups(&older[0], &newer[0], &turns).is_none());
}
@@ -0,0 +1,82 @@
//! Apply live exploration compatibility to replay and repair groups split by history pages.
use super::tools::CommandHistory;
use crate::exec_cell::ExecCell;
use crate::history_cell::HistoryCell;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
use std::sync::Arc;
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn join_exploration_groups(
older: &Arc<dyn HistoryCell>,
newer: &Arc<dyn HistoryCell>,
turns: &[Turn],
) -> Option<Arc<dyn HistoryCell>> {
let older = older.as_any().downcast_ref::<ExecCell>()?;
let newer = newer.as_any().downcast_ref::<ExecCell>()?;
let items = adjacent_items(older, newer, turns)?;
let mut group = completed_group(items)?;
group.group.details = newer.group.details.clone();
group
.group
.details
.prepend(older.group.details.clone(), older.group.calls.len());
Some(Arc::new(group))
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn older_exploration_group(
older: &dyn HistoryCell,
newer: &ExecCell,
turns: &[Turn],
) -> Option<ExecCell> {
let older = older.as_any().downcast_ref::<ExecCell>()?;
let items = adjacent_items(older, newer, turns)?;
let older_items = items
.iter()
.filter(|item| !matches!(item, ThreadItem::Reasoning { .. }))
.take(older.group.calls.len())
.cloned()
.collect::<Vec<_>>();
let mut group = completed_group(&older_items)?;
group.group.details = older.group.details.clone();
Some(group)
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
fn adjacent_items<'a>(
older: &ExecCell,
newer: &ExecCell,
turns: &'a [Turn],
) -> Option<&'a [ThreadItem]> {
if !older.is_exploring_cell() || older.should_flush() || !newer.is_exploring_cell() {
return None;
}
let ids = older
.iter_calls()
.chain(newer.iter_calls())
.map(|call| call.call_id.as_str())
.collect::<Vec<_>>();
super::computer_groups::adjacent_activity_items(&ids, turns)
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(super) fn completed_group(items: &[ThreadItem]) -> Option<ExecCell> {
let mut group: Option<ExecCell> = None;
for item in items {
if matches!(item, ThreadItem::Reasoning { .. }) {
continue;
}
let call = CommandHistory::from_item(item.clone())?.into_cell();
if !call.is_exploring_cell() {
return None;
}
if let Some(group) = &mut group {
group.append_completed(call).ok()?;
} else {
group = Some(call);
}
}
group
}