mirror of
https://github.com/openai/codex.git
synced 2026-10-14 16:38:43 +00:00
Render dynamic tool activity and preserve TUI history ordering (#46731)
## Why The TUI ignored dynamic tool items, and replayed tool activity could differ from live output. Concurrent tool completion also needs to preserve transcript order when writing to terminal scrollback. ## What changed - Retain dynamic tool rows, update them when calls finish out of order, and mark unfinished calls interrupted when a turn ends. - Queue terminal output behind unfinished dynamic tools and drain settled rows in bounded batches. Preserve queued output through stream consolidation, resize, and rollback without emitting it twice. - Align replayed file changes, MCP results, and exploration groups with live rendering. Keep hidden reasoning and terminal interaction details within compatible activity groups, and preserve finalized plan fragments. - Ignore stale history-page responses, bound turn metadata requests to missing turns, and insert older history after session headers. Keep hidden review prompts as activity-group boundaries. ## Testing Add regression tests and snapshots for out-of-order tool completion, interruption, ordered scrollback emission, rollback, deferred usage updates, replay consistency, overlapping history, and repeated hidden prompts. GitOrigin-RevId: a3636cfcf4c9c43a0a5bc52a842b80a7834f1409
This commit is contained in:
@@ -207,6 +207,7 @@ mod agents_overview_details;
|
||||
mod agents_overview_threads;
|
||||
mod agents_overview_usage;
|
||||
mod agents_overview_view;
|
||||
mod native_history;
|
||||
pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID;
|
||||
mod app_server_event_targets;
|
||||
mod app_server_events;
|
||||
@@ -568,6 +569,7 @@ pub(crate) struct App {
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
|
||||
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
||||
native_history: native_history::NativeHistory,
|
||||
last_rendered_history_tail: Option<history_ui::RenderedHistoryTail>,
|
||||
last_thread_usage_status_cell: Option<history_ui::ThreadUsageStatusHistory>,
|
||||
pub(crate) pending_thread_usage_history_refresh: bool,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! tail in the bottom pane. Once the answer finishes, the app replaces that
|
||||
//! trailing run with a single source-backed `AgentMarkdownCell`. This makes the
|
||||
//! transcript the canonical owner of the raw markdown source used for future
|
||||
//! resize re-renders.
|
||||
//! resize re-renders. The retained view remaps its reading position before that
|
||||
//! replacement, then repaints without replaying terminal scrollback.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -39,6 +40,7 @@ impl App {
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.insert_cell(cell.clone());
|
||||
}
|
||||
self.native_history.defer(&cell);
|
||||
self.transcript_cells.push(cell);
|
||||
}
|
||||
|
||||
@@ -61,6 +63,8 @@ impl App {
|
||||
inline_visualization_context,
|
||||
),
|
||||
);
|
||||
self.native_history
|
||||
.consolidate(&self.transcript_cells[start..end], &consolidated);
|
||||
self.transcript_cells
|
||||
.splice(start..end, std::iter::once(consolidated.clone()));
|
||||
|
||||
|
||||
@@ -952,7 +952,11 @@ impl App {
|
||||
let turns = match thread.history_mode {
|
||||
ThreadHistoryMode::Paginated if app_server.supports_paginated_history() => {
|
||||
app_server
|
||||
.thread_turns_page(thread_id, /*cursor*/ None)
|
||||
.thread_turns_page(
|
||||
thread_id,
|
||||
/*cursor*/ None,
|
||||
crate::app_server_session::INITIAL_HISTORY_TURN_LIMIT,
|
||||
)
|
||||
.await?
|
||||
.data
|
||||
}
|
||||
|
||||
@@ -738,6 +738,7 @@ impl App {
|
||||
};
|
||||
|
||||
self.transcript_cells.remove(index);
|
||||
self.native_history.retain(&self.transcript_cells);
|
||||
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.replace_cells(self.transcript_cells.clone());
|
||||
}
|
||||
|
||||
@@ -298,7 +298,8 @@ impl App {
|
||||
.handle_older_history_page(tui, app_server, thread_id, &cursor, result)
|
||||
.await
|
||||
{
|
||||
app_server.cancel_older_history_page(thread_id);
|
||||
app_server.cancel_older_history_page(thread_id, &cursor);
|
||||
|
||||
if self.chat_widget.thread_id() == Some(thread_id)
|
||||
&& let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut()
|
||||
{
|
||||
@@ -701,6 +702,7 @@ impl App {
|
||||
if self.chat_widget.thread_id() == Some(thread_id) {
|
||||
if let Some(index) = crate::app_backtrack::nth_user_position(&self.transcript_cells, nth_user_message) {
|
||||
self.transcript_cells.truncate(index);
|
||||
self.native_history.retain(&self.transcript_cells);
|
||||
}
|
||||
self.deferred_history_lines.clear();
|
||||
self.last_rendered_history_tail = None;
|
||||
@@ -735,6 +737,7 @@ impl App {
|
||||
.chat_widget
|
||||
.thread_id()
|
||||
.is_some_and(|thread_id| app_server.has_older_history(thread_id));
|
||||
|
||||
self.finish_initial_history_replay_buffer(tui);
|
||||
}
|
||||
AppEvent::ConsolidateAgentMessage {
|
||||
@@ -764,6 +767,7 @@ impl App {
|
||||
Arc::new(history_cell::new_proposed_plan(source, &self.config.cwd));
|
||||
|
||||
if start < end {
|
||||
self.native_history.consolidate(&self.transcript_cells[start..end], &consolidated);
|
||||
self.transcript_cells
|
||||
.splice(start..end, std::iter::once(consolidated.clone()));
|
||||
|
||||
@@ -774,17 +778,13 @@ impl App {
|
||||
|
||||
self.finish_required_stream_reflow(tui)?;
|
||||
} else {
|
||||
let deferred = self.native_history.insert(&consolidated);
|
||||
self.transcript_cells.push(consolidated.clone());
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.insert_cell(consolidated.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.insert_history_cell_lines(
|
||||
tui,
|
||||
consolidated.as_ref(),
|
||||
self.chat_widget
|
||||
.history_wrap_width(tui.terminal.last_known_screen_size.width),
|
||||
);
|
||||
self.render_inserted_history_cell(tui, &consolidated, deferred);
|
||||
|
||||
self.maybe_finish_stream_reflow(tui)?;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Load older transcript pages without rewriting terminal-native scrollback.
|
||||
//! Load bounded history pages into the transcript before updating its owned or inline viewport.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT;
|
||||
use crate::app_server_session::thread_items_page_params;
|
||||
use crate::history_cell::SessionHeaderHistoryCell;
|
||||
use crate::history_cell::SessionInfoCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
use crate::pager_overlay::TranscriptHistoryState;
|
||||
@@ -65,8 +66,11 @@ impl App {
|
||||
cursor: &str,
|
||||
result: Result<ThreadItemsListResponse, String>,
|
||||
) -> Result<()> {
|
||||
if !app_server.is_older_history_page_pending(thread_id, cursor) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.chat_widget.thread_id() != Some(thread_id) {
|
||||
app_server.cancel_older_history_page(thread_id);
|
||||
app_server.cancel_older_history_page(thread_id, cursor);
|
||||
return Ok(());
|
||||
}
|
||||
let page = result.map_err(|err| color_eyre::eyre::eyre!(err))?;
|
||||
@@ -75,7 +79,7 @@ impl App {
|
||||
.get(&thread_id)
|
||||
.map(|channel| Arc::clone(&channel.store))
|
||||
else {
|
||||
app_server.cancel_older_history_page(thread_id);
|
||||
app_server.cancel_older_history_page(thread_id, cursor);
|
||||
return Ok(());
|
||||
};
|
||||
let (cwd, mut turns) = {
|
||||
@@ -91,110 +95,124 @@ impl App {
|
||||
let items = app_server
|
||||
.apply_older_history_page(thread_id, cursor, page, &mut turns)
|
||||
.await?;
|
||||
let mut hidden_item_ids = HashSet::new();
|
||||
let mut review_mode = false;
|
||||
for (index, turn) in turns.iter().enumerate() {
|
||||
let hidden_nested_review_turn = index
|
||||
.checked_sub(/*rhs*/ 1)
|
||||
.and_then(|previous| turns.get(previous))
|
||||
.is_some_and(|previous| {
|
||||
crate::app_backtrack::is_hidden_nested_review_turn(previous, turn)
|
||||
});
|
||||
for item in &turn.items {
|
||||
match item {
|
||||
ThreadItem::EnteredReviewMode { .. } => review_mode = true,
|
||||
ThreadItem::ExitedReviewMode { .. } => review_mode = false,
|
||||
ThreadItem::UserMessage { .. } if review_mode || hidden_nested_review_turn => {
|
||||
hidden_item_ids.insert(item.id());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let hidden_item_ids = hidden_review_item_ids(&turns);
|
||||
let visibility = if self.config.show_raw_agent_reasoning {
|
||||
RawReasoningVisibility::Visible
|
||||
} else {
|
||||
RawReasoningVisibility::Hidden
|
||||
};
|
||||
let width = tui.terminal.last_known_screen_size.width;
|
||||
if !hidden_item_ids.is_empty() {
|
||||
let user_items = turns
|
||||
.iter()
|
||||
.flat_map(|turn| turn.items.iter())
|
||||
.filter(|item| matches!(item, ThreadItem::UserMessage { .. }))
|
||||
.map(|item| (item.id().to_string(), item.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let projected_user_cells = thread_items_to_transcript_cells(
|
||||
Some(thread_id),
|
||||
&cwd,
|
||||
user_items.iter().map(|(_, item)| item.clone()),
|
||||
visibility,
|
||||
Some(&self.config),
|
||||
);
|
||||
let mut persisted_user_cells = user_items
|
||||
.into_iter()
|
||||
.zip(projected_user_cells)
|
||||
.map(|((item_id, _), cell)| (item_id, cell))
|
||||
.collect::<Vec<_>>();
|
||||
let mut hidden_transcript_indices = Vec::new();
|
||||
for (index, cell) in self.transcript_cells.iter().enumerate().rev() {
|
||||
let Some(user_cell) = cell.as_any().downcast_ref::<UserHistoryCell>() else {
|
||||
continue;
|
||||
};
|
||||
let Some(position) = persisted_user_cells.iter().rposition(|(_, projected)| {
|
||||
projected
|
||||
.as_any()
|
||||
.downcast_ref::<UserHistoryCell>()
|
||||
.is_some_and(|projected| {
|
||||
projected.message == user_cell.message
|
||||
&& projected.text_elements == user_cell.text_elements
|
||||
&& projected.local_image_paths == user_cell.local_image_paths
|
||||
&& projected.remote_image_urls == user_cell.remote_image_urls
|
||||
})
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if hidden_item_ids.contains(persisted_user_cells[position].0.as_str()) {
|
||||
hidden_transcript_indices.push(index);
|
||||
}
|
||||
persisted_user_cells.truncate(position);
|
||||
}
|
||||
if !hidden_transcript_indices.is_empty() {
|
||||
if self.backtrack.overlay_preview_active {
|
||||
let selected_index = crate::app_backtrack::nth_user_position(
|
||||
&self.transcript_cells,
|
||||
self.backtrack.nth_user_message,
|
||||
);
|
||||
let removed_visible_users = hidden_transcript_indices
|
||||
.iter()
|
||||
.filter(|&&index| {
|
||||
selected_index.is_some_and(|selected| index < selected)
|
||||
&& self.transcript_cells[index].desired_height(width) != 0
|
||||
})
|
||||
.count();
|
||||
self.backtrack.nth_user_message = self
|
||||
.backtrack
|
||||
.nth_user_message
|
||||
.saturating_sub(removed_visible_users);
|
||||
}
|
||||
for index in hidden_transcript_indices {
|
||||
self.transcript_cells.remove(index);
|
||||
}
|
||||
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
|
||||
overlay.replace_cells(self.transcript_cells.clone());
|
||||
}
|
||||
}
|
||||
self.remove_hidden_review_cells(
|
||||
&turns,
|
||||
&hidden_item_ids,
|
||||
thread_id,
|
||||
&cwd,
|
||||
visibility,
|
||||
width,
|
||||
);
|
||||
let cells = self.project_older_history_cells(
|
||||
items,
|
||||
&turns,
|
||||
&hidden_item_ids,
|
||||
thread_id,
|
||||
&cwd,
|
||||
visibility,
|
||||
);
|
||||
self.prepend_older_transcript_cells(cells, width);
|
||||
merge_older_turns(&mut store.lock().await.turns, turns);
|
||||
self.scrollback_has_older_history = app_server.has_older_history(thread_id);
|
||||
|
||||
self.finish_inline_history_page(tui, app_server, thread_id, width);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove prompts revealed as internal review input by an older page's review marker.
|
||||
fn remove_hidden_review_cells(
|
||||
&mut self,
|
||||
turns: &[Turn],
|
||||
hidden_item_ids: &HashSet<&str>,
|
||||
thread_id: ThreadId,
|
||||
cwd: &AbsolutePathBuf,
|
||||
visibility: RawReasoningVisibility,
|
||||
width: u16,
|
||||
) {
|
||||
if hidden_item_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let user_items = turns
|
||||
.iter()
|
||||
.flat_map(|turn| turn.items.iter())
|
||||
.filter(|item| matches!(item, ThreadItem::UserMessage { .. }))
|
||||
.map(|item| (item.id().to_string(), item.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let projected_user_cells = thread_items_to_transcript_cells(
|
||||
Some(thread_id),
|
||||
cwd,
|
||||
user_items.iter().map(|(_, item)| item.clone()),
|
||||
visibility,
|
||||
Some(&self.config),
|
||||
);
|
||||
let persisted_user_cells = user_items
|
||||
.into_iter()
|
||||
.zip(projected_user_cells)
|
||||
.map(|((item_id, _), cell)| (item_id, cell))
|
||||
.collect();
|
||||
let indices = hidden_transcript_indices(
|
||||
&self.transcript_cells,
|
||||
persisted_user_cells,
|
||||
hidden_item_ids,
|
||||
);
|
||||
if indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.backtrack.overlay_preview_active {
|
||||
let selected_index = crate::app_backtrack::nth_user_position(
|
||||
&self.transcript_cells,
|
||||
self.backtrack.nth_user_message,
|
||||
);
|
||||
let removed_visible_users = indices
|
||||
.iter()
|
||||
.filter(|&&index| {
|
||||
selected_index.is_some_and(|selected| index < selected)
|
||||
&& self.transcript_cells[index].desired_height(width) != 0
|
||||
})
|
||||
.count();
|
||||
self.backtrack.nth_user_message = self
|
||||
.backtrack
|
||||
.nth_user_message
|
||||
.saturating_sub(removed_visible_users);
|
||||
}
|
||||
for index in indices {
|
||||
self.transcript_cells.remove(index);
|
||||
}
|
||||
self.native_history.retain(&self.transcript_cells);
|
||||
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
|
||||
overlay.replace_cells(self.transcript_cells.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Project successful turn completion metadata after the corresponding page's final item.
|
||||
fn project_older_history_cells(
|
||||
&mut self,
|
||||
items: Vec<ThreadItem>,
|
||||
turns: &[Turn],
|
||||
hidden_item_ids: &HashSet<&str>,
|
||||
thread_id: ThreadId,
|
||||
cwd: &AbsolutePathBuf,
|
||||
visibility: RawReasoningVisibility,
|
||||
) -> Vec<Arc<dyn HistoryCell>> {
|
||||
let mut cells = Vec::new();
|
||||
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,
|
||||
items,
|
||||
visibility,
|
||||
Some(&self.config),
|
||||
));
|
||||
for (items, completed_turn) in completion::group_completed_turn_items(items, turns) {
|
||||
// Internal prompts stay invisible, but still separate adjacent tool groups.
|
||||
for visible in items.split(|item| hidden_item_ids.contains(item.id())) {
|
||||
cells.extend(thread_items_to_transcript_cells(
|
||||
Some(thread_id),
|
||||
cwd,
|
||||
visible.iter().cloned(),
|
||||
visibility,
|
||||
Some(&self.config),
|
||||
));
|
||||
}
|
||||
if let Some(turn) = completed_turn
|
||||
&& let Some(completion) = self
|
||||
.chat_widget
|
||||
@@ -203,22 +221,11 @@ impl App {
|
||||
cells.push(Arc::new(completion));
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut store = store.lock().await;
|
||||
turns.retain_mut(|turn| {
|
||||
let Some(current) = store.turns.iter_mut().find(|current| current.id == turn.id)
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let items = std::mem::take(&mut turn.items)
|
||||
.into_iter()
|
||||
.filter(|item| !current.items.iter().any(|known| known.id() == item.id()))
|
||||
.collect::<Vec<_>>();
|
||||
current.items.splice(0..0, items);
|
||||
false
|
||||
});
|
||||
store.turns.splice(0..0, turns);
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
/// Insert each page once, keeping session headers and any backtrack selection in place.
|
||||
fn prepend_older_transcript_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>, width: u16) {
|
||||
if self.backtrack.overlay_preview_active {
|
||||
self.backtrack.nth_user_message = self.backtrack.nth_user_message.saturating_add(
|
||||
cells
|
||||
@@ -229,11 +236,30 @@ impl App {
|
||||
.count(),
|
||||
);
|
||||
}
|
||||
self.scrollback_has_older_history = app_server.has_older_history(thread_id);
|
||||
let index = if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
|
||||
overlay.prepend(cells.clone())
|
||||
} else {
|
||||
self.transcript_cells
|
||||
.iter()
|
||||
.rposition(|cell| {
|
||||
cell.as_any().is::<SessionInfoCell>()
|
||||
|| cell.as_any().is::<SessionHeaderHistoryCell>()
|
||||
})
|
||||
.map_or(/*default*/ 0, |index| index.saturating_add(/*rhs*/ 1))
|
||||
};
|
||||
self.transcript_cells.splice(index..index, cells);
|
||||
}
|
||||
|
||||
/// Preserve the legacy overlay and inline scrollback refill behavior.
|
||||
fn finish_inline_history_page(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
width: u16,
|
||||
) {
|
||||
let mut continue_to_start = false;
|
||||
if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() {
|
||||
let index = overlay.prepend(cells.clone());
|
||||
self.transcript_cells.splice(index..index, cells);
|
||||
let previous_state = overlay.set_history_state(if self.scrollback_has_older_history {
|
||||
TranscriptHistoryState::Partial
|
||||
} else {
|
||||
@@ -242,12 +268,6 @@ impl App {
|
||||
continue_to_start = previous_state == TranscriptHistoryState::LoadingBeginning
|
||||
&& self.scrollback_has_older_history;
|
||||
} else {
|
||||
let index = self
|
||||
.transcript_cells
|
||||
.iter()
|
||||
.rposition(|cell| cell.as_any().is::<SessionInfoCell>())
|
||||
.map_or(/*default*/ 0, |index| index.saturating_add(/*rhs*/ 1));
|
||||
self.transcript_cells.splice(index..index, cells);
|
||||
let wrap_width = self.chat_widget.history_wrap_width(width);
|
||||
let rendered_rows = self
|
||||
.render_transcript_lines_for_reflow(wrap_width)
|
||||
@@ -257,7 +277,7 @@ impl App {
|
||||
if self.scrollback_history_needs_top_up(rendered_rows)
|
||||
&& self.request_older_history_page(app_server, thread_id)
|
||||
{
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
}
|
||||
if continue_to_start
|
||||
@@ -270,6 +290,85 @@ impl App {
|
||||
self.apply_backtrack_selection_internal(self.backtrack.nth_user_message);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify internal review prompts across page boundaries before rendering any older items.
|
||||
fn hidden_review_item_ids(turns: &[Turn]) -> HashSet<&str> {
|
||||
let mut hidden = HashSet::new();
|
||||
let mut review_mode = false;
|
||||
for (index, turn) in turns.iter().enumerate() {
|
||||
let hidden_nested_review_turn = index
|
||||
.checked_sub(/*rhs*/ 1)
|
||||
.and_then(|previous| turns.get(previous))
|
||||
.is_some_and(|previous| {
|
||||
crate::app_backtrack::is_hidden_nested_review_turn(previous, turn)
|
||||
});
|
||||
for item in &turn.items {
|
||||
match item {
|
||||
ThreadItem::EnteredReviewMode { .. } => review_mode = true,
|
||||
ThreadItem::ExitedReviewMode { .. } => review_mode = false,
|
||||
ThreadItem::UserMessage { .. } if review_mode || hidden_nested_review_turn => {
|
||||
hidden.insert(item.id());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Match repeated user messages from newest to oldest so identical visible prompts stay distinct.
|
||||
fn hidden_transcript_indices(
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
mut persisted_user_cells: Vec<(String, Arc<dyn HistoryCell>)>,
|
||||
hidden_item_ids: &HashSet<&str>,
|
||||
) -> Vec<usize> {
|
||||
let mut indices = Vec::new();
|
||||
for (index, cell) in cells.iter().enumerate().rev() {
|
||||
let Some(user_cell) = cell.as_any().downcast_ref::<UserHistoryCell>() else {
|
||||
continue;
|
||||
};
|
||||
let Some(position) = persisted_user_cells.iter().rposition(|(_, projected)| {
|
||||
projected
|
||||
.as_any()
|
||||
.downcast_ref::<UserHistoryCell>()
|
||||
.is_some_and(|projected| {
|
||||
projected.message == user_cell.message
|
||||
&& projected.text_elements == user_cell.text_elements
|
||||
&& projected.local_image_paths == user_cell.local_image_paths
|
||||
&& projected.remote_image_urls == user_cell.remote_image_urls
|
||||
})
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
if hidden_item_ids.contains(persisted_user_cells[position].0.as_str()) {
|
||||
indices.push(index);
|
||||
}
|
||||
persisted_user_cells.truncate(position);
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
/// Preserve events received while the request was in flight and deduplicate overlapping pages.
|
||||
fn merge_older_turns(current_turns: &mut Vec<Turn>, mut older_turns: Vec<Turn>) {
|
||||
older_turns.retain_mut(|turn| {
|
||||
let Some(current) = current_turns
|
||||
.iter_mut()
|
||||
.find(|current| current.id == turn.id)
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
let items = std::mem::take(&mut turn.items)
|
||||
.into_iter()
|
||||
.filter(|item| !current.items.iter().any(|known| known.id() == item.id()))
|
||||
.collect::<Vec<_>>();
|
||||
current.items.splice(0..0, items);
|
||||
false
|
||||
});
|
||||
current_turns.splice(0..0, older_turns);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "history_pagination_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Older pages must preserve newer events and identify repeated hidden prompts precisely.
|
||||
|
||||
use super::*;
|
||||
use crate::app::test_support::make_test_app;
|
||||
use crate::history_cell::ComputerActivityCell;
|
||||
use crate::history_cell::FinalMessageSeparator;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn turn(id: &str, status: TurnStatus, item_ids: &[&str]) -> Turn {
|
||||
Turn {
|
||||
id: id.to_string(),
|
||||
items: item_ids
|
||||
.iter()
|
||||
.map(|id| ThreadItem::UserMessage {
|
||||
id: id.to_string(),
|
||||
client_id: None,
|
||||
content: Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
items_view: TurnItemsView::Full,
|
||||
status,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_history_keeps_live_turn_state_and_newer_items() {
|
||||
let mut current = vec![turn("shared", TurnStatus::InProgress, &["overlap", "live"])];
|
||||
let older = vec![
|
||||
turn("old", TurnStatus::Completed, &["first"]),
|
||||
turn("shared", TurnStatus::Completed, &["before", "overlap"]),
|
||||
];
|
||||
merge_older_turns(&mut current, older);
|
||||
assert_eq!(
|
||||
current,
|
||||
vec![
|
||||
turn("old", TurnStatus::Completed, &["first"]),
|
||||
turn(
|
||||
"shared",
|
||||
TurnStatus::InProgress,
|
||||
&["before", "overlap", "live"]
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn user_cell(message: &str) -> Arc<dyn HistoryCell> {
|
||||
Arc::new(UserHistoryCell {
|
||||
spoken: false,
|
||||
message: message.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_prompt_matching_removes_only_the_hidden_occurrence() {
|
||||
let cells = vec![user_cell("repeat"), user_cell("other"), user_cell("repeat")];
|
||||
let persisted = vec![
|
||||
("unloaded".to_string(), user_cell("repeat")),
|
||||
("hidden".to_string(), user_cell("repeat")),
|
||||
("middle".to_string(), user_cell("other")),
|
||||
("visible".to_string(), user_cell("repeat")),
|
||||
];
|
||||
assert_eq!(
|
||||
hidden_transcript_indices(&cells, persisted, &HashSet::from(["hidden", "unloaded"])),
|
||||
vec![0],
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hidden_last_item_keeps_turn_groups_and_completion_boundaries() {
|
||||
let computer = |id: &str| {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "mcpToolCall", "id": id, "server": "cua_repl", "tool": "js",
|
||||
"status": "completed", "arguments": {"title": format!("Inspect {id}")},
|
||||
"appContext": null, "pluginId": null, "readOnlyHint": null,
|
||||
"result": {"content": [], "structuredContent": null},
|
||||
"error": null, "durationMs": 10
|
||||
}))
|
||||
.expect("valid completed computer call")
|
||||
};
|
||||
for status in [
|
||||
TurnStatus::Completed,
|
||||
TurnStatus::Failed,
|
||||
TurnStatus::Interrupted,
|
||||
] {
|
||||
let mut app = make_test_app().await;
|
||||
let cwd = app.config.cwd.clone();
|
||||
let thread_id = ThreadId::new();
|
||||
let expected_completions = usize::from(status == TurnStatus::Completed);
|
||||
let mut first = turn("first", status, &[]);
|
||||
first.items = vec![
|
||||
ThreadItem::EnteredReviewMode {
|
||||
id: "enter-review".to_string(),
|
||||
review: "current changes".to_string(),
|
||||
},
|
||||
computer("older-call"),
|
||||
ThreadItem::UserMessage {
|
||||
id: "hidden".to_string(),
|
||||
client_id: None,
|
||||
content: vec![UserInput::Text {
|
||||
text: "internal review prompt".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
first.completed_at = Some(1_700_000_000);
|
||||
first.duration_ms = Some(125_000);
|
||||
let mut second = turn("second", TurnStatus::InProgress, &[]);
|
||||
second.items = vec![computer("newer-call")];
|
||||
let turns = vec![first, second];
|
||||
let hidden = hidden_review_item_ids(&turns);
|
||||
assert_eq!(hidden, HashSet::from(["hidden"]));
|
||||
let items = turns
|
||||
.iter()
|
||||
.flat_map(|turn| turn.items.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let cells = app.project_older_history_cells(
|
||||
items.clone(),
|
||||
&turns,
|
||||
&hidden,
|
||||
thread_id,
|
||||
&cwd,
|
||||
RawReasoningVisibility::Hidden,
|
||||
);
|
||||
let groups = cells
|
||||
.iter()
|
||||
.filter_map(|cell| cell.as_any().downcast_ref::<ComputerActivityCell>())
|
||||
.map(|cell| cell.call_ids().map(str::to_string).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>();
|
||||
let completions = cells
|
||||
.iter()
|
||||
.filter(|cell| cell.as_any().is::<FinalMessageSeparator>())
|
||||
.count();
|
||||
let visible_users = cells
|
||||
.iter()
|
||||
.filter(|cell| cell.as_any().is::<UserHistoryCell>())
|
||||
.count();
|
||||
assert_eq!(
|
||||
(groups, completions, visible_users),
|
||||
(
|
||||
vec![
|
||||
vec!["older-call".to_string()],
|
||||
vec!["newer-call".to_string()]
|
||||
],
|
||||
expected_completions,
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
let repeated = app.project_older_history_cells(
|
||||
items,
|
||||
&turns,
|
||||
&hidden,
|
||||
thread_id,
|
||||
&cwd,
|
||||
RawReasoningVisibility::Hidden,
|
||||
);
|
||||
assert_eq!(
|
||||
repeated
|
||||
.iter()
|
||||
.filter(|cell| cell.as_any().is::<FinalMessageSeparator>())
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,33 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
self.transcript_cells.push(cell.clone());
|
||||
let deferred = self.native_history.insert(&cell);
|
||||
self.render_inserted_history_cell(tui, &cell, deferred);
|
||||
// A committed cell can unblock a settled /usage card that was waiting
|
||||
// behind a transient active cell or a provisional stream tail.
|
||||
self.chat_widget.request_pending_usage_output_insertion();
|
||||
if is_session_header {
|
||||
self.merge_startup_warnings(tui, &history_cell::StartupWarningsCell::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Track mutable status cards before choosing retained or terminal-owned rendering.
|
||||
pub(super) fn render_inserted_history_cell(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
cell: &Arc<dyn HistoryCell>,
|
||||
deferred: bool,
|
||||
) {
|
||||
let width = self
|
||||
.chat_widget
|
||||
.history_wrap_width(tui.terminal.last_known_screen_size.width);
|
||||
let lines =
|
||||
cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode());
|
||||
// Owned replay must not eagerly format every historical entry. Composite status cards are
|
||||
// the only committed cells whose mutable usage data needs insertion-time bookkeeping.
|
||||
let lines = if !deferred || cell.as_any().is::<history_cell::CompositeHistoryCell>() {
|
||||
cell.display_hyperlink_lines_for_mode(width, self.chat_widget.history_render_mode())
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if cell.as_any().is::<history_cell::CompositeHistoryCell>()
|
||||
&& lines.first().is_some_and(|line| {
|
||||
line.line.spans.len() == 1 && line.line.spans[0].content.as_ref() == "/status"
|
||||
@@ -49,10 +71,14 @@ impl App {
|
||||
{
|
||||
self.last_thread_usage_status_cell = Some(ThreadUsageStatusHistory {
|
||||
thread_id,
|
||||
cell: Arc::downgrade(&cell),
|
||||
cell: Arc::downgrade(cell),
|
||||
lines: lines.clone(),
|
||||
});
|
||||
}
|
||||
if deferred {
|
||||
tui.frame_requester().schedule_frame();
|
||||
return;
|
||||
}
|
||||
if self.initial_history_replay_buffer.as_ref().is_some() {
|
||||
self.insert_history_cell_lines_with_initial_replay_buffer(tui, cell.as_ref(), width);
|
||||
self.last_rendered_history_tail = None;
|
||||
@@ -60,19 +86,13 @@ impl App {
|
||||
self.insert_history_cell_lines(tui, cell.as_ref(), width);
|
||||
self.last_rendered_history_tail = if self.overlay.is_none() && !lines.is_empty() {
|
||||
Some(RenderedHistoryTail {
|
||||
cell: Arc::downgrade(&cell),
|
||||
cell: Arc::downgrade(cell),
|
||||
lines,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
// A committed cell can unblock a settled /usage card that was waiting
|
||||
// behind a transient active cell or a provisional stream tail.
|
||||
self.chat_widget.request_pending_usage_output_insertion();
|
||||
if is_session_header {
|
||||
self.merge_startup_warnings(tui, &history_cell::StartupWarningsCell::default());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_thread_usage_refresh(
|
||||
@@ -120,6 +140,12 @@ impl App {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// A queued card reads the latest usage when it is first emitted.
|
||||
if self.native_history.contains(&status_cell) {
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let width = self
|
||||
.chat_widget
|
||||
.history_wrap_width(tui.terminal.last_known_screen_size.width);
|
||||
@@ -246,6 +272,13 @@ impl App {
|
||||
width: u16,
|
||||
version: &'static str,
|
||||
) -> Vec<Line<'static>> {
|
||||
self.clear_ui_header_cell(version).display_lines(width)
|
||||
}
|
||||
|
||||
fn clear_ui_header_cell(
|
||||
&self,
|
||||
version: &'static str,
|
||||
) -> history_cell::SessionHeaderHistoryCell {
|
||||
history_cell::SessionHeaderHistoryCell::new(
|
||||
self.chat_widget.model_display_name().to_string(),
|
||||
self.chat_widget.current_reasoning_effort(),
|
||||
@@ -257,7 +290,6 @@ impl App {
|
||||
version,
|
||||
)
|
||||
.with_yolo_mode(history_cell::is_yolo_mode(&self.config))
|
||||
.display_lines(width)
|
||||
}
|
||||
|
||||
pub(super) fn clear_ui_header_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
@@ -315,6 +347,7 @@ impl App {
|
||||
pub(super) fn reset_transcript_state_after_clear(&mut self) {
|
||||
self.overlay = None;
|
||||
self.transcript_cells.clear();
|
||||
self.native_history = Default::default();
|
||||
self.last_rendered_history_tail = None;
|
||||
self.last_thread_usage_status_cell = None;
|
||||
self.pending_thread_usage_history_refresh = false;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Queue native terminal output behind unfinished dynamic tools.
|
||||
//! Fullscreen rendering reads retained cells directly and never queues terminal output.
|
||||
//!
|
||||
//! Entries own their render source independently of the retained view. Consolidation remaps their
|
||||
//! canonical owner, so pagination, pointer reuse, and replacing stream fragments cannot cause a
|
||||
//! second emission. Clearing the transcript starts a new journal; rollback prunes removed owners.
|
||||
|
||||
use super::*;
|
||||
|
||||
const CELLS_PER_FRAME: usize = 32;
|
||||
|
||||
struct PendingCell {
|
||||
source: Arc<dyn HistoryCell>,
|
||||
owner: std::sync::Weak<dyn HistoryCell>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct NativeHistory {
|
||||
pending: VecDeque<PendingCell>,
|
||||
}
|
||||
|
||||
pub(super) fn is_pending(cell: &dyn HistoryCell) -> bool {
|
||||
cell.as_any()
|
||||
.downcast_ref::<history_cell::DynamicToolCallCell>()
|
||||
.is_some_and(history_cell::DynamicToolCallCell::is_active)
|
||||
}
|
||||
|
||||
impl NativeHistory {
|
||||
pub(super) fn contains(&self, cell: &Arc<dyn HistoryCell>) -> bool {
|
||||
self.pending
|
||||
.iter()
|
||||
.any(|entry| Arc::ptr_eq(&entry.source, cell))
|
||||
}
|
||||
|
||||
pub(super) fn insert(&mut self, cell: &Arc<dyn HistoryCell>) -> bool {
|
||||
if is_pending(cell.as_ref()) || !self.pending.is_empty() {
|
||||
self.defer(cell);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn defer(&mut self, cell: &Arc<dyn HistoryCell>) {
|
||||
self.pending.push_back(PendingCell {
|
||||
source: cell.clone(),
|
||||
owner: Arc::downgrade(cell),
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace an entirely unprinted stream with final source. For a partly printed stream, keep
|
||||
/// only its remaining fragments; never replay the prefix already owned by the terminal.
|
||||
pub(super) fn consolidate(
|
||||
&mut self,
|
||||
previous: &[Arc<dyn HistoryCell>],
|
||||
consolidated: &Arc<dyn HistoryCell>,
|
||||
) {
|
||||
let belongs = |entry: &PendingCell| {
|
||||
previous
|
||||
.iter()
|
||||
.any(|cell| entry.owner.ptr_eq(&Arc::downgrade(cell)))
|
||||
};
|
||||
let all_pending = !previous.is_empty() && previous.iter().all(|cell| self.contains(cell));
|
||||
if all_pending && let Some(index) = self.pending.iter().position(&belongs) {
|
||||
self.pending.retain(|entry| !belongs(entry));
|
||||
// Keep consolidated output before unrelated cells that were queued after it.
|
||||
self.pending.insert(
|
||||
index,
|
||||
PendingCell {
|
||||
source: consolidated.clone(),
|
||||
owner: Arc::downgrade(consolidated),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
for entry in self.pending.iter_mut().filter(|entry| belongs(entry)) {
|
||||
entry.owner = Arc::downgrade(consolidated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn retain(&mut self, cells: &[Arc<dyn HistoryCell>]) {
|
||||
self.pending.retain(|entry| {
|
||||
cells
|
||||
.iter()
|
||||
.any(|cell| entry.owner.ptr_eq(&Arc::downgrade(cell)))
|
||||
});
|
||||
}
|
||||
|
||||
/// Historical in-progress records are not live blockers: only queued owners can hold replay.
|
||||
pub(super) fn replayable_cells<'a>(
|
||||
&self,
|
||||
cells: &'a [Arc<dyn HistoryCell>],
|
||||
) -> &'a [Arc<dyn HistoryCell>] {
|
||||
let end = self
|
||||
.pending
|
||||
.iter()
|
||||
.find(|entry| entry.owner.strong_count() > 0 && is_pending(entry.source.as_ref()))
|
||||
.and_then(|entry| {
|
||||
cells
|
||||
.iter()
|
||||
.position(|cell| entry.owner.ptr_eq(&Arc::downgrade(cell)))
|
||||
})
|
||||
.unwrap_or(cells.len());
|
||||
&cells[..end]
|
||||
}
|
||||
|
||||
/// A deliberate native resize/rollback replay supersedes queued settled output.
|
||||
pub(super) fn replayed(&mut self) {
|
||||
let mut blocked = false;
|
||||
self.pending.retain(|entry| {
|
||||
blocked |= is_pending(entry.source.as_ref());
|
||||
blocked
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn flush_native_history(&mut self, tui: &mut tui::Tui) {
|
||||
if self.overlay.is_some() {
|
||||
return;
|
||||
}
|
||||
let mut ready = Vec::new();
|
||||
while let Some(entry) = self.native_history.pending.front() {
|
||||
if entry.owner.strong_count() == 0 {
|
||||
self.native_history.pending.pop_front();
|
||||
continue;
|
||||
}
|
||||
if ready.len() == CELLS_PER_FRAME || is_pending(entry.source.as_ref()) {
|
||||
break;
|
||||
}
|
||||
ready.push(Arc::clone(&entry.source));
|
||||
self.native_history.pending.pop_front();
|
||||
}
|
||||
for cell in ready {
|
||||
self.render_inserted_history_cell(tui, &cell, /*deferred*/ false);
|
||||
}
|
||||
if self
|
||||
.native_history
|
||||
.pending
|
||||
.front()
|
||||
.is_some_and(|entry| !is_pending(entry.source.as_ref()))
|
||||
{
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "native_history_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Native tool completion is emitted once, before output queued behind it.
|
||||
|
||||
use super::*;
|
||||
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
|
||||
use codex_app_server_protocol::DynamicToolCallStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn settled_tool_precedes_queued_output_and_is_not_emitted_twice() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let item = |status| ThreadItem::DynamicToolCall {
|
||||
id: "lookup".into(),
|
||||
namespace: None,
|
||||
tool: "lookup".into(),
|
||||
arguments: serde_json::json!({}),
|
||||
status,
|
||||
success: None,
|
||||
duration_ms: None,
|
||||
content_items: Some(vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: "unique result".into(),
|
||||
}]),
|
||||
};
|
||||
let cell =
|
||||
history_cell::DynamicToolCallCell::from_item(item(DynamicToolCallStatus::InProgress))
|
||||
.unwrap();
|
||||
// Stale in-progress history is not in the live queue and cannot hide later context.
|
||||
app.transcript_cells.push(Arc::new(cell.clone()));
|
||||
app.transcript_cells
|
||||
.push(Arc::new(history_cell::PlainHistoryCell::new(vec![
|
||||
"past context".into(),
|
||||
])));
|
||||
app.insert_history_cell(&mut tui, Box::new(cell.clone()));
|
||||
app.flush_native_history(&mut tui);
|
||||
assert!(tui.pending_history_lines_for_test().is_empty());
|
||||
// A long-lived tool must not lose output queued behind it.
|
||||
for index in 0..4097 {
|
||||
app.insert_history_cell(
|
||||
&mut tui,
|
||||
Box::new(history_cell::PlainHistoryCell::new(vec![
|
||||
format!("later output {index}").into(),
|
||||
])),
|
||||
);
|
||||
}
|
||||
app.flush_native_history(&mut tui);
|
||||
assert!(tui.pending_history_lines_for_test().is_empty());
|
||||
app.native_history.replayed();
|
||||
assert!(
|
||||
app.render_transcript_lines_for_reflow(/*width*/ 80)
|
||||
.lines
|
||||
.iter()
|
||||
.any(|line| line.line.to_string().contains("past context"))
|
||||
);
|
||||
let historical = app.render_transcript_lines_for_reflow(/*width*/ 80).lines;
|
||||
assert!(
|
||||
historical
|
||||
.iter()
|
||||
.any(|line| line.line.to_string().contains("Calling lookup"))
|
||||
);
|
||||
cell.update_from_item(item(DynamicToolCallStatus::Completed));
|
||||
for _ in 0..=4097 / CELLS_PER_FRAME {
|
||||
app.flush_native_history(&mut tui);
|
||||
}
|
||||
let lines = tui.pending_history_lines_for_test();
|
||||
let text = lines
|
||||
.iter()
|
||||
.map(|line| line.line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_eq!(text.matches("unique result").count(), 1);
|
||||
assert_eq!(text.matches("later output").count(), 4097);
|
||||
assert!(text.find("unique result").unwrap() < text.find("later output").unwrap());
|
||||
app.flush_native_history(&mut tui);
|
||||
assert_eq!(tui.pending_history_lines_for_test(), lines);
|
||||
// Rollback replay consumes even a queue larger than one frame's drain budget.
|
||||
for cell in app.transcript_cells.iter().rev().take(CELLS_PER_FRAME + 1) {
|
||||
app.native_history.defer(cell);
|
||||
}
|
||||
app.rebuild_transcript_after_backtrack(
|
||||
&mut tui,
|
||||
ratatui::layout::Size::new(/*width*/ 80, /*height*/ 24).into(),
|
||||
)?;
|
||||
let replayed = tui.pending_history_lines_for_test();
|
||||
app.flush_native_history(&mut tui);
|
||||
assert_eq!(tui.pending_history_lines_for_test(), replayed);
|
||||
Ok(())
|
||||
}
|
||||
@@ -153,6 +153,7 @@ impl App {
|
||||
};
|
||||
|
||||
self.transcript_cells.remove(index);
|
||||
self.native_history.retain(&self.transcript_cells);
|
||||
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.replace_cells(self.transcript_cells.clone());
|
||||
}
|
||||
|
||||
@@ -397,6 +397,7 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
size: ratatui::layout::Size,
|
||||
) -> Result<()> {
|
||||
self.flush_native_history(tui);
|
||||
let should_rebuild_transcript = self.handle_draw_size_change(
|
||||
size,
|
||||
tui.terminal.last_known_screen_size,
|
||||
@@ -474,6 +475,7 @@ impl App {
|
||||
return Ok(terminal_width);
|
||||
}
|
||||
|
||||
self.native_history.replayed();
|
||||
let reflow_result = self.render_transcript_lines_for_reflow(width);
|
||||
let reflowed_lines = reflow_result.lines;
|
||||
let reflowed_rows = reflowed_lines.len();
|
||||
@@ -489,16 +491,17 @@ impl App {
|
||||
self.history_line_wrap_policy(),
|
||||
);
|
||||
}
|
||||
self.last_rendered_history_tail =
|
||||
self.transcript_cells
|
||||
.last()
|
||||
.map(|cell| super::history_ui::RenderedHistoryTail {
|
||||
cell: Arc::downgrade(cell),
|
||||
lines: cell.display_hyperlink_lines_for_mode(
|
||||
width,
|
||||
self.chat_widget.history_render_mode(),
|
||||
),
|
||||
});
|
||||
self.last_rendered_history_tail = self
|
||||
.native_history
|
||||
.replayable_cells(&self.transcript_cells)
|
||||
.last()
|
||||
.map(|cell| super::history_ui::RenderedHistoryTail {
|
||||
cell: Arc::downgrade(cell),
|
||||
lines: cell.display_hyperlink_lines_for_mode(
|
||||
width,
|
||||
self.chat_widget.history_render_mode(),
|
||||
),
|
||||
});
|
||||
if let Some(status_history) = self.last_thread_usage_status_cell.as_mut()
|
||||
&& let Some(cell) = status_history.cell.upgrade()
|
||||
{
|
||||
@@ -547,6 +550,7 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
terminal_width: TerminalWidth,
|
||||
) -> Result<()> {
|
||||
self.native_history.replayed();
|
||||
let width = self.chat_widget.history_wrap_width(terminal_width.0);
|
||||
let reflowed_lines = if self.transcript_cells.is_empty() {
|
||||
self.reset_history_emission_state();
|
||||
@@ -580,7 +584,10 @@ impl App {
|
||||
let row_cap = self.resize_reflow_max_rows();
|
||||
let mut cell_displays = VecDeque::new();
|
||||
let mut rendered_rows = 0usize;
|
||||
let mut start = self.transcript_cells.len();
|
||||
let mut start = self
|
||||
.native_history
|
||||
.replayable_cells(&self.transcript_cells)
|
||||
.len();
|
||||
let mut history_was_truncated = false;
|
||||
|
||||
while start > 0 {
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::session_lifecycle::ThreadAttachPresentation;
|
||||
use super::*;
|
||||
use crate::app_server_session::ForkGoalContinuation;
|
||||
use crate::app_server_session::HISTORY_ITEM_PAGE_LIMIT;
|
||||
use crate::app_server_session::INITIAL_HISTORY_TURN_LIMIT;
|
||||
use crate::app_server_session::turn_permissions_overrides;
|
||||
use crate::chatwidget::ThreadInputState;
|
||||
use crate::chatwidget::ThreadInputStateRestoreMode;
|
||||
@@ -115,7 +116,7 @@ impl App {
|
||||
.await?;
|
||||
} else {
|
||||
let page = app_server
|
||||
.thread_turns_page(thread_id, /*cursor*/ None)
|
||||
.thread_turns_page(thread_id, /*cursor*/ None, INITIAL_HISTORY_TURN_LIMIT)
|
||||
.await?;
|
||||
thread.turns = page.data.into_iter().rev().collect();
|
||||
if let Some(turn_index) = thread.turns.iter().position(|turn| turn.id == turn_id) {
|
||||
|
||||
@@ -741,6 +741,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
keymap: runtime_keymap,
|
||||
key_chord_matcher: KeyChordMatcher::default(),
|
||||
transcript_cells: Vec::new(),
|
||||
native_history: Default::default(),
|
||||
last_rendered_history_tail: None,
|
||||
last_thread_usage_status_cell: None,
|
||||
pending_thread_usage_history_refresh: false,
|
||||
|
||||
@@ -43,8 +43,12 @@ impl App {
|
||||
.rposition(|cell| cell.as_any().is::<history_cell::SessionInfoCell>());
|
||||
warnings.pending_header = header.is_none() && self.chat_widget.thread_id().is_none();
|
||||
let ready_to_render = !warnings.pending_header;
|
||||
self.transcript_cells
|
||||
.insert(header.map_or(0, |index| index + 1), Arc::new(warnings));
|
||||
let warnings: Arc<dyn HistoryCell> = Arc::new(warnings);
|
||||
self.transcript_cells.insert(
|
||||
header.map_or(/*default*/ 0, |index| index + 1),
|
||||
warnings.clone(),
|
||||
);
|
||||
self.native_history.retain(&self.transcript_cells);
|
||||
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
|
||||
overlay.replace_cells(self.transcript_cells.clone());
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
pending_server_profiles: HashMap::new(),
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
native_history: Default::default(),
|
||||
last_rendered_history_tail: None,
|
||||
last_thread_usage_status_cell: None,
|
||||
pending_thread_usage_history_refresh: false,
|
||||
|
||||
@@ -5911,6 +5911,7 @@ async fn make_test_app() -> App {
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
native_history: Default::default(),
|
||||
last_rendered_history_tail: None,
|
||||
last_thread_usage_status_cell: None,
|
||||
pending_thread_usage_history_refresh: false,
|
||||
@@ -6012,6 +6013,7 @@ pub(super) async fn make_test_app_with_channels() -> (
|
||||
runtime_permission_profile_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
native_history: Default::default(),
|
||||
last_rendered_history_tail: None,
|
||||
last_thread_usage_status_cell: None,
|
||||
pending_thread_usage_history_refresh: false,
|
||||
|
||||
@@ -3120,7 +3120,11 @@ async fn cold_paginated_subagent_transcript_excludes_inherited_parent_history()
|
||||
)
|
||||
.await?;
|
||||
let child_turn_page = app_server
|
||||
.thread_turns_page(child_thread_id, /*cursor*/ None)
|
||||
.thread_turns_page(
|
||||
child_thread_id,
|
||||
/*cursor*/ None,
|
||||
crate::app_server_session::INITIAL_HISTORY_TURN_LIMIT,
|
||||
)
|
||||
.await?;
|
||||
let child_item_page = app_server
|
||||
.thread_items_page(
|
||||
|
||||
@@ -271,6 +271,22 @@ async fn account_change_discards_thread_usage_deferred_while_overlay_is_open() -
|
||||
async fn terminal_reflow_rebases_pending_status_update_to_new_width() -> Result<()> {
|
||||
let (mut app, mut app_server, mut tui, thread_id, request_id) =
|
||||
app_with_pending_thread_usage().await?;
|
||||
app.insert_history_cell(
|
||||
&mut tui,
|
||||
Box::new(
|
||||
history_cell::DynamicToolCallCell::from_item(ThreadItem::DynamicToolCall {
|
||||
id: "pending-tool".into(),
|
||||
namespace: None,
|
||||
tool: "lookup".into(),
|
||||
arguments: serde_json::json!({}),
|
||||
status: codex_app_server_protocol::DynamicToolCallStatus::InProgress,
|
||||
success: None,
|
||||
duration_ms: None,
|
||||
content_items: None,
|
||||
})
|
||||
.unwrap(),
|
||||
),
|
||||
);
|
||||
let original = app
|
||||
.last_rendered_history_tail
|
||||
.as_ref()
|
||||
@@ -310,3 +326,24 @@ async fn terminal_reflow_rebases_pending_status_update_to_new_width() -> Result<
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_status_reads_refreshed_usage_only_when_emitted() -> Result<()> {
|
||||
let (mut app, mut app_server, mut tui, thread_id, request_id) =
|
||||
app_with_pending_thread_usage().await?;
|
||||
let status = Arc::clone(app.transcript_cells.last().unwrap());
|
||||
tui.clear_pending_history_lines();
|
||||
app.native_history.defer(&status);
|
||||
app.render_inserted_history_cell(&mut tui, &status, /*deferred*/ true);
|
||||
app.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
successful_thread_usage(thread_id, request_id),
|
||||
)
|
||||
.await?;
|
||||
assert!(tui.pending_history_lines_for_test().is_empty());
|
||||
app.flush_native_history(&mut tui);
|
||||
assert_eq!(pending_history_text(&tui).matches("50 credits").count(), 1);
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ mod collaboration_catalog_tests;
|
||||
pub(crate) use history::HISTORY_ITEM_PAGE_LIMIT;
|
||||
pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT;
|
||||
pub(crate) use history::HistoryHydrationScope;
|
||||
pub(crate) use history::INITIAL_HISTORY_TURN_LIMIT;
|
||||
pub(crate) use history::thread_items_page_params;
|
||||
|
||||
use crate::app_event::PermissionProfileSelection;
|
||||
|
||||
@@ -102,8 +102,8 @@ impl AppServerSession {
|
||||
..ThreadHistoryPagination::default()
|
||||
},
|
||||
);
|
||||
} else {
|
||||
self.cancel_older_history_page(thread_id);
|
||||
} else if let Some(page) = self.history_pagination.get_mut(&thread_id) {
|
||||
page.loading_older = false;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -124,8 +124,17 @@ impl AppServerSession {
|
||||
Some(cursor)
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_older_history_page(&mut self, thread_id: ThreadId) {
|
||||
if let Some(page) = self.history_pagination.get_mut(&thread_id) {
|
||||
/// Match a completion to the page still pending for this thread, before interpreting its result.
|
||||
pub(crate) fn is_older_history_page_pending(&self, thread_id: ThreadId, cursor: &str) -> bool {
|
||||
self.history_pagination.get(&thread_id).is_some_and(|page| {
|
||||
page.loading_older && page.next_item_cursor.as_deref() == Some(cursor)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_older_history_page(&mut self, thread_id: ThreadId, cursor: &str) {
|
||||
if let Some(page) = self.history_pagination.get_mut(&thread_id)
|
||||
&& page.next_item_cursor.as_deref() == Some(cursor)
|
||||
{
|
||||
page.loading_older = false;
|
||||
}
|
||||
}
|
||||
@@ -137,12 +146,12 @@ impl AppServerSession {
|
||||
page: ThreadItemsListResponse,
|
||||
turns: &mut Vec<Turn>,
|
||||
) -> Result<Vec<ThreadItem>> {
|
||||
if !self.is_older_history_page_pending(thread_id, cursor) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let Some(mut state) = self.history_pagination.get(&thread_id).cloned() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
if !state.loading_older || state.next_item_cursor.as_deref() != Some(cursor) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let items = self
|
||||
.merge_thread_item_page(thread_id, page, &mut state, turns)
|
||||
.await?;
|
||||
@@ -172,6 +181,7 @@ impl AppServerSession {
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<ThreadTurnsListResponse> {
|
||||
let request_id = self.next_request_id();
|
||||
self.client
|
||||
@@ -180,7 +190,7 @@ impl AppServerSession {
|
||||
params: ThreadTurnsListParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
cursor,
|
||||
limit: Some(INITIAL_HISTORY_TURN_LIMIT),
|
||||
limit: Some(limit),
|
||||
sort_direction: Some(SortDirection::Desc),
|
||||
items_view: Some(TurnItemsView::NotLoaded),
|
||||
},
|
||||
@@ -201,20 +211,32 @@ impl AppServerSession {
|
||||
page.next_cursor,
|
||||
&mut state.seen_item_cursors,
|
||||
);
|
||||
let mut missing_turn_ids = page
|
||||
.data
|
||||
.iter()
|
||||
.filter(|entry| !turns.iter().any(|turn| turn.id == entry.turn_id))
|
||||
.map(|entry| entry.turn_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut items = Vec::new();
|
||||
for entry in page.data {
|
||||
while !turns.iter().any(|turn| turn.id == entry.turn_id) {
|
||||
let Some(cursor) = state.next_turn_cursor.take() else {
|
||||
break;
|
||||
};
|
||||
// Fetch only the remaining item-backed turns so metadata does not run ahead
|
||||
// of this page. Empty turns between them may require another bounded request.
|
||||
let limit = missing_turn_ids.len().min(HISTORY_ITEM_PAGE_LIMIT as usize) as u32;
|
||||
let page = self
|
||||
.thread_turns_page(thread_id, Some(cursor.clone()))
|
||||
.thread_turns_page(thread_id, Some(cursor.clone()), limit)
|
||||
.await?;
|
||||
state.next_turn_cursor = advancing_cursor(
|
||||
Some(&cursor),
|
||||
page.next_cursor,
|
||||
&mut state.seen_turn_cursors,
|
||||
);
|
||||
for turn in &page.data {
|
||||
missing_turn_ids.remove(&turn.id);
|
||||
}
|
||||
turns.splice(0..0, page.data.into_iter().rev());
|
||||
}
|
||||
if let Some(turn) = turns.iter_mut().find(|turn| turn.id == entry.turn_id)
|
||||
@@ -254,7 +276,9 @@ impl AppServerSession {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let page = self.thread_turns_page(thread_id, turn_cursor).await?;
|
||||
let page = self
|
||||
.thread_turns_page(thread_id, turn_cursor, INITIAL_HISTORY_TURN_LIMIT)
|
||||
.await?;
|
||||
thread.turns = page.data.into_iter().rev().collect();
|
||||
let mut state = ThreadHistoryPagination {
|
||||
history_mode: ThreadHistoryMode::Paginated,
|
||||
|
||||
@@ -316,6 +316,7 @@ use crate::status_indicator_widget::STATUS_DETAILS_DEFAULT_MAX_LINES;
|
||||
use crate::status_indicator_widget::StatusDetailsCapitalization;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
mod activity_groups;
|
||||
mod command_lifecycle;
|
||||
mod connector_mentions;
|
||||
mod connectors;
|
||||
@@ -443,6 +444,7 @@ use self::status_surfaces::CachedProjectRootName;
|
||||
mod thread_title_status;
|
||||
mod thread_usage;
|
||||
pub(crate) use self::thread_usage::ThreadUsageOutcome;
|
||||
mod dynamic_activity;
|
||||
mod tool_lifecycle;
|
||||
mod tool_requests;
|
||||
mod transcript;
|
||||
@@ -1250,6 +1252,9 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
fn add_boxed_history(&mut self, cell: Box<dyn HistoryCell>) {
|
||||
let Err(cell) = self.absorb_activity_detail(cell) else {
|
||||
return;
|
||||
};
|
||||
if let Some(active) = self.take_history_insertion_prefix(cell.as_ref()) {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
|
||||
self.request_pending_usage_output_insertion();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Keep hidden reasoning and terminal bookkeeping inside compatible activity groups.
|
||||
//! Detailed output retains the original order while compact activity avoids artificial breaks.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn absorb_activity_detail(
|
||||
&mut self,
|
||||
cell: Box<dyn HistoryCell>,
|
||||
) -> Result<(), Box<dyn HistoryCell>> {
|
||||
let hidden_reasoning = cell
|
||||
.as_any()
|
||||
.downcast_ref::<history_cell::ReasoningSummaryCell>()
|
||||
.is_some_and(history_cell::ReasoningSummaryCell::is_transcript_only);
|
||||
if !hidden_reasoning
|
||||
&& !cell
|
||||
.as_any()
|
||||
.is::<history_cell::UnifiedExecInteractionCell>()
|
||||
{
|
||||
return Err(cell);
|
||||
}
|
||||
let Some(active) = self.transcript.active_cell.as_mut() else {
|
||||
return Err(cell);
|
||||
};
|
||||
if let Some(computer) = active
|
||||
.as_any_mut()
|
||||
.downcast_mut::<history_cell::ComputerActivityCell>()
|
||||
{
|
||||
computer.group.push_detail(Arc::from(cell));
|
||||
self.bump_active_cell_revision();
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(exploration) = active.as_any_mut().downcast_mut::<ExecCell>()
|
||||
&& exploration.is_exploring_cell()
|
||||
{
|
||||
exploration.group.push_detail(Arc::from(cell));
|
||||
self.bump_active_cell_revision();
|
||||
return Ok(());
|
||||
}
|
||||
Err(cell)
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ impl ChatWidget {
|
||||
return;
|
||||
};
|
||||
let cell = history_cell::new_unified_exec_interaction(wait.command_display, String::new());
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||||
if let Err(cell) = self.absorb_activity_detail(Box::new(cell)) {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
|
||||
}
|
||||
self.restore_reasoning_status_header();
|
||||
}
|
||||
|
||||
@@ -368,6 +369,25 @@ impl ChatWidget {
|
||||
let is_unified_exec_interaction =
|
||||
matches!(source, ExecCommandSource::UnifiedExecInteraction);
|
||||
let is_user_shell = source == ExecCommandSource::UserShell;
|
||||
// Completion-only replay has no begin event to join adjacent exploration. Extend only
|
||||
// a finished, compatible group; an unrelated running group must retain orphan routing.
|
||||
if let Some(cell) = self
|
||||
.transcript
|
||||
.active_cell
|
||||
.as_mut()
|
||||
.and_then(|cell| cell.as_any_mut().downcast_mut::<ExecCell>())
|
||||
&& !cell.is_active()
|
||||
&& !cell.should_flush()
|
||||
&& !cell.iter_calls().any(|call| call.call_id == id)
|
||||
{
|
||||
cell.add_call(
|
||||
id.clone(),
|
||||
command.clone(),
|
||||
parsed.clone(),
|
||||
source,
|
||||
/*interaction_input*/ None,
|
||||
);
|
||||
}
|
||||
let end_target = match self.transcript.active_cell.as_ref() {
|
||||
Some(cell) => match cell.as_any().downcast_ref::<ExecCell>() {
|
||||
Some(exec_cell) if exec_cell.iter_calls().any(|call| call.call_id == id) => {
|
||||
@@ -389,8 +409,6 @@ impl ChatWidget {
|
||||
None => ExecEndTarget::NewCell,
|
||||
};
|
||||
|
||||
// Unified exec interaction rows intentionally hide command output text in the exec cell and
|
||||
// instead render the interaction-specific content elsewhere in the UI.
|
||||
let output = if is_unified_exec_interaction {
|
||||
CommandOutput::new(exit_code, String::new())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Retain dynamic tool state in either renderer; the app emits only finished rows to terminal history.
|
||||
|
||||
use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn on_dynamic_tool_item(&mut self, item: ThreadItem) {
|
||||
let started = matches!(
|
||||
&item,
|
||||
ThreadItem::DynamicToolCall {
|
||||
status: codex_app_server_protocol::DynamicToolCallStatus::InProgress,
|
||||
..
|
||||
}
|
||||
);
|
||||
self.defer_or_handle(
|
||||
item,
|
||||
|interrupts, item| {
|
||||
if started {
|
||||
interrupts.push_item_started(item);
|
||||
} else {
|
||||
interrupts.push_item_completed(item);
|
||||
}
|
||||
},
|
||||
Self::handle_dynamic_tool_item_now,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_dynamic_tool_item_now(&mut self, item: ThreadItem) {
|
||||
let ThreadItem::DynamicToolCall { id, .. } = &item else {
|
||||
return;
|
||||
};
|
||||
let id = id.clone();
|
||||
if let Some(cell) = self.transcript.dynamic_calls.get(&id) {
|
||||
cell.update_from_item(item);
|
||||
if !cell.is_active() {
|
||||
self.transcript.dynamic_calls.remove(&id);
|
||||
}
|
||||
} else if let Some(cell) = history_cell::DynamicToolCallCell::from_item(item) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
if cell.is_active() {
|
||||
self.transcript
|
||||
.dynamic_calls
|
||||
.insert(cell.call_id().to_owned(), cell.clone());
|
||||
}
|
||||
self.add_to_history(cell);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(super) fn finish_dynamic_activity(&mut self) {
|
||||
for (_, cell) in self.transcript.dynamic_calls.drain() {
|
||||
cell.mark_interrupted();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -537,6 +537,7 @@ impl ChatWidget {
|
||||
self.on_patch_apply_begin(file_update_changes_to_display(changes));
|
||||
}
|
||||
item @ ThreadItem::McpToolCall { .. } => self.on_mcp_tool_call_started(item),
|
||||
item @ ThreadItem::DynamicToolCall { .. } => self.on_dynamic_tool_item(item),
|
||||
ThreadItem::WebSearch(item) => {
|
||||
self.on_web_search_begin(item.id);
|
||||
}
|
||||
|
||||
@@ -245,7 +245,21 @@ impl ChatWidget {
|
||||
turn_id: String,
|
||||
replay_kind: ReplayKind,
|
||||
) {
|
||||
self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind));
|
||||
match item {
|
||||
// Snapshots contain the completed item, without the live start that renders its diff.
|
||||
ThreadItem::FileChange {
|
||||
changes,
|
||||
status: codex_app_server_protocol::PatchApplyStatus::Completed,
|
||||
..
|
||||
} => {
|
||||
if !changes.is_empty() {
|
||||
self.on_patch_apply_begin(file_update_changes_to_display(changes));
|
||||
}
|
||||
}
|
||||
item => {
|
||||
self.handle_thread_item(item, turn_id, ThreadItemRenderSource::Replay(replay_kind));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn handle_thread_item(
|
||||
@@ -458,7 +472,7 @@ impl ChatWidget {
|
||||
agents_states,
|
||||
}),
|
||||
item @ ThreadItem::SubAgentActivity { .. } => self.on_sub_agent_activity(item),
|
||||
ThreadItem::DynamicToolCall { .. } => {}
|
||||
item @ ThreadItem::DynamicToolCall { .. } => self.on_dynamic_tool_item(item),
|
||||
ThreadItem::Sleep(_) => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,10 +64,9 @@ impl ChatWidget {
|
||||
pub(super) fn flush_answer_and_plan_streams(&mut self) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
if let Some(mut controller) = self.plan_stream_controller.take() {
|
||||
let had_live_tail = controller.has_live_tail();
|
||||
self.clear_active_stream_tail();
|
||||
let (cell, source) = controller.finalize();
|
||||
if !had_live_tail && let Some(cell) = cell {
|
||||
if let Some(cell) = cell {
|
||||
self.add_boxed_history(cell);
|
||||
}
|
||||
if let Some(source) = source {
|
||||
@@ -256,10 +255,10 @@ impl ChatWidget {
|
||||
self.transcript.saw_plan_item_this_turn = true;
|
||||
let (finalized_streamed_cell, consolidated_plan_source) =
|
||||
if let Some(mut controller) = self.plan_stream_controller.take() {
|
||||
let had_live_tail = controller.has_live_tail();
|
||||
let source_only = controller.has_live_tail() && controller.tail_starts_stream();
|
||||
self.clear_active_stream_tail();
|
||||
let (cell, source) = controller.finalize();
|
||||
if had_live_tail {
|
||||
if source_only {
|
||||
(None, source)
|
||||
} else {
|
||||
(cell, source)
|
||||
@@ -349,8 +348,12 @@ 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: Box<dyn HistoryCell> =
|
||||
let mut cell =
|
||||
history_cell::new_reasoning_summary_block(reasoning_parts, &self.config.cwd);
|
||||
if let Some(id) = &self.status_state.reasoning_item_id {
|
||||
cell.set_source_item_id(id.clone());
|
||||
}
|
||||
let cell: Box<dyn HistoryCell> = cell;
|
||||
let result = match self.transcript.active_cell.as_mut() {
|
||||
Some(active) => active.append_reasoning(cell),
|
||||
None => Err(cell),
|
||||
|
||||
@@ -249,6 +249,7 @@ mod computer_activity_tests;
|
||||
mod config_errors;
|
||||
#[path = "tests/copy_export_picker_tests.rs"]
|
||||
mod copy_export_picker_tests;
|
||||
mod dynamic_activity_tests;
|
||||
mod exec_flow;
|
||||
mod goal_menu;
|
||||
mod goal_validation;
|
||||
|
||||
@@ -1074,20 +1074,21 @@ async fn startup_config_warning_is_not_repeated_by_thread() {
|
||||
async fn live_app_server_file_change_item_started_preserves_changes() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
let item = AppServerThreadItem::FileChange {
|
||||
id: "patch-1".to_string(),
|
||||
changes: vec![FileUpdateChange {
|
||||
path: "foo.txt".to_string(),
|
||||
kind: PatchChangeKind::Add,
|
||||
diff: "hello\n".to_string(),
|
||||
}],
|
||||
status: AppServerPatchApplyStatus::InProgress,
|
||||
};
|
||||
chat.handle_server_notification(
|
||||
ServerNotification::ItemStarted(ItemStartedNotification {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
started_at_ms: 0,
|
||||
item: AppServerThreadItem::FileChange {
|
||||
id: "patch-1".to_string(),
|
||||
changes: vec![FileUpdateChange {
|
||||
path: "foo.txt".to_string(),
|
||||
kind: PatchChangeKind::Add,
|
||||
diff: "hello\n".to_string(),
|
||||
}],
|
||||
status: AppServerPatchApplyStatus::InProgress,
|
||||
},
|
||||
item: item.clone(),
|
||||
}),
|
||||
/*replay_kind*/ None,
|
||||
);
|
||||
@@ -1095,10 +1096,25 @@ async fn live_app_server_file_change_item_started_preserves_changes() {
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert!(!cells.is_empty(), "expected patch history to be rendered");
|
||||
let transcript = lines_to_single_string(cells.last().expect("patch cell"));
|
||||
assert!(
|
||||
transcript.contains("Added foo.txt") || transcript.contains("Edited foo.txt"),
|
||||
"expected patch summary to include foo.txt, got: {transcript}"
|
||||
let AppServerThreadItem::FileChange { id, changes, .. } = item else {
|
||||
unreachable!()
|
||||
};
|
||||
chat.replay_thread_item(
|
||||
AppServerThreadItem::FileChange {
|
||||
id,
|
||||
changes,
|
||||
status: AppServerPatchApplyStatus::Completed,
|
||||
},
|
||||
"turn-1".to_string(),
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
let replayed = drain_insert_history(&mut rx);
|
||||
assert_eq!(replayed.len(), 1);
|
||||
assert_eq!(lines_to_single_string(&replayed[0]), transcript);
|
||||
insta::assert_snapshot!(transcript, @"
|
||||
• Added foo.txt (+1 -0)
|
||||
1 +hello
|
||||
");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Dynamic calls must finish visibly whether transcript rows are retained or terminal-owned.
|
||||
|
||||
use super::*;
|
||||
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
|
||||
use codex_app_server_protocol::DynamicToolCallStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
fn dynamic_item(id: &str, status: DynamicToolCallStatus) -> AppServerThreadItem {
|
||||
let completed = status != DynamicToolCallStatus::InProgress;
|
||||
AppServerThreadItem::DynamicToolCall {
|
||||
id: id.to_string(),
|
||||
namespace: Some("test".to_string()),
|
||||
tool: "lookup".to_string(),
|
||||
arguments: json!({ "id": id }),
|
||||
status,
|
||||
content_items: completed.then(|| {
|
||||
vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: format!("Result for {id}"),
|
||||
}]
|
||||
}),
|
||||
success: None,
|
||||
duration_ms: completed.then_some(/*t*/ 25),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dynamic_activity_retains_out_of_order_completions() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
drain_insert_history(&mut rx);
|
||||
|
||||
for id in ["call-1", "call-2"] {
|
||||
chat.on_dynamic_tool_item(dynamic_item(id, DynamicToolCallStatus::InProgress));
|
||||
}
|
||||
let cells = std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(cells.len(), 2);
|
||||
assert_eq!(chat.transcript.dynamic_calls.len(), 2);
|
||||
|
||||
chat.on_dynamic_tool_item(dynamic_item("call-2", DynamicToolCallStatus::Completed));
|
||||
chat.on_dynamic_tool_item(dynamic_item("call-1", DynamicToolCallStatus::Failed));
|
||||
assert!(drain_insert_history(&mut rx).is_empty());
|
||||
let output = cells
|
||||
.iter()
|
||||
.flat_map(|cell| cell.display_lines(/*width*/ 80))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(chat.transcript.dynamic_calls.is_empty());
|
||||
insta::assert_snapshot!("terminal_dynamic_activity", lines_to_single_string(&output));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_finalization_drains_lifecycle_queue_before_interrupting_remaining_calls() {
|
||||
let (mut chat, mut events, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
drain_insert_history(&mut events);
|
||||
chat.interrupts
|
||||
.push_item_started(dynamic_item("completed", DynamicToolCallStatus::InProgress));
|
||||
chat.interrupts
|
||||
.push_item_completed(dynamic_item("completed", DynamicToolCallStatus::Completed));
|
||||
chat.interrupts.push_item_started(dynamic_item(
|
||||
"unfinished",
|
||||
DynamicToolCallStatus::InProgress,
|
||||
));
|
||||
chat.finalize_turn();
|
||||
let cells = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell)
|
||||
if cell.as_any().is::<history_cell::DynamicToolCallCell>() =>
|
||||
{
|
||||
Some(cell)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(cells.len(), 2);
|
||||
assert!(chat.transcript.dynamic_calls.is_empty());
|
||||
let raw = cells
|
||||
.iter()
|
||||
.map(|cell| lines_to_single_string(&cell.raw_lines()))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(raw[0].contains("Result for completed"));
|
||||
assert!(!raw[0].contains("Interrupted"));
|
||||
assert!(raw[1].contains("Interrupted before this tool returned a result."));
|
||||
let lines = cells[1].display_lines(/*width*/ 64);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
/*y*/ 0,
|
||||
/*width*/ 64,
|
||||
lines.len() as u16,
|
||||
);
|
||||
let mut buffer = Buffer::empty(area);
|
||||
Paragraph::new(lines).render(area, &mut buffer);
|
||||
insta::assert_snapshot!("interrupted_dynamic_activity", format!("{buffer:?}"));
|
||||
}
|
||||
@@ -1,10 +1,178 @@
|
||||
//! Persisted reasoning matches live replay in compact and detailed presentations.
|
||||
//! Persisted tool cells must match initial replay in legacy and detailed presentations.
|
||||
|
||||
use super::*;
|
||||
use crate::thread_transcript::RawReasoningVisibility;
|
||||
use crate::thread_transcript::thread_items_to_transcript_cells;
|
||||
use codex_app_server_protocol::McpToolCallResult;
|
||||
use codex_app_server_protocol::McpToolCallStatus;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn older_tool_projection_matches_initial_replay() {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
drain_insert_history(&mut rx);
|
||||
let command = "cargo check";
|
||||
let mut items = vec![AppServerThreadItem::CommandExecution {
|
||||
id: "command".to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
model_context: None,
|
||||
command: command.to_string(),
|
||||
cwd: chat.config.cwd.clone().into(),
|
||||
process_id: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
status: AppServerCommandExecutionStatus::Completed,
|
||||
command_actions: vec![AppServerCommandAction::Unknown {
|
||||
command: command.to_string(),
|
||||
}],
|
||||
aggregated_output: Some(
|
||||
(1..=12)
|
||||
.map(|line| format!("Checking crate {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
),
|
||||
exit_code: Some(0),
|
||||
duration_ms: Some(25),
|
||||
}];
|
||||
// Completion-only replay must group consecutive exploration just like older-page projection.
|
||||
for name in ["one.rs", "two.rs"] {
|
||||
let command = format!("cat {name}");
|
||||
items.push(AppServerThreadItem::CommandExecution {
|
||||
id: name.to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
model_context: None,
|
||||
command: command.clone(),
|
||||
cwd: chat.config.cwd.clone().into(),
|
||||
process_id: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
status: AppServerCommandExecutionStatus::Completed,
|
||||
command_actions: vec![AppServerCommandAction::Read {
|
||||
command,
|
||||
name: name.to_string(),
|
||||
path: chat.config.cwd.join(name).into(),
|
||||
}],
|
||||
aggregated_output: Some("fn main() {}".to_string()),
|
||||
exit_code: Some(0),
|
||||
duration_ms: Some(5),
|
||||
});
|
||||
}
|
||||
// Four adjacent CUA calls legacy together; a regular MCP call is a group boundary.
|
||||
for (server, id) in [
|
||||
("cua_repl", "1"),
|
||||
("cua_repl", "2"),
|
||||
("cua_repl", "3"),
|
||||
("cua_repl", "4"),
|
||||
("example", "mcp"),
|
||||
("cua_repl", "5"),
|
||||
] {
|
||||
if id == "2" {
|
||||
items.push(AppServerThreadItem::Reasoning {
|
||||
id: "computer-reasoning".to_string(),
|
||||
summary: vec!["**Inspecting the page**".to_string()],
|
||||
content: Vec::new(),
|
||||
});
|
||||
}
|
||||
items.push(AppServerThreadItem::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),
|
||||
});
|
||||
}
|
||||
// Switching back to exploration must flush the computer group, while reasoning stays
|
||||
// inside the active exploration group until the completed turn commits it.
|
||||
for (index, id) in [(1, "trailing-read-one"), (2, "trailing-read-two")] {
|
||||
if index == 2 {
|
||||
items.push(AppServerThreadItem::Reasoning {
|
||||
id: "exploration-reasoning".to_string(),
|
||||
summary: vec!["**Checking the implementation**".to_string()],
|
||||
content: Vec::new(),
|
||||
});
|
||||
}
|
||||
let mut read = items[index].clone();
|
||||
if let AppServerThreadItem::CommandExecution { id: read_id, .. } = &mut read {
|
||||
*read_id = id.to_string();
|
||||
}
|
||||
items.push(read);
|
||||
}
|
||||
let projected = thread_items_to_transcript_cells(
|
||||
chat.thread_id,
|
||||
&chat.config.cwd,
|
||||
items.clone(),
|
||||
RawReasoningVisibility::Hidden,
|
||||
Some(&chat.config),
|
||||
);
|
||||
chat.replay_thread_turns(
|
||||
vec![AppServerTurn {
|
||||
items,
|
||||
..app_server_turn(
|
||||
"turn",
|
||||
AppServerTurnStatus::Completed,
|
||||
/*duration_ms*/ None,
|
||||
/*error*/ None,
|
||||
)
|
||||
}],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
// Even without an answer or saved completion label, the completed turn must commit its
|
||||
// last tool group so an older page can join the same canonical history cells.
|
||||
assert!(chat.transcript.active_cell.is_none());
|
||||
let replayed = std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell)
|
||||
if !cell.as_any().is::<history_cell::FinalMessageSeparator>() =>
|
||||
{
|
||||
Some(cell)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for width in [40, 100] {
|
||||
let render =
|
||||
|cell: &dyn HistoryCell| (cell.display_lines(width), cell.transcript_lines(width));
|
||||
assert_eq!(
|
||||
projected
|
||||
.iter()
|
||||
.map(|cell| render(cell.as_ref()))
|
||||
.collect::<Vec<_>>(),
|
||||
replayed
|
||||
.iter()
|
||||
.map(|cell| render(cell.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
let exploration = replayed
|
||||
.iter()
|
||||
.find(|cell| {
|
||||
cell.as_any()
|
||||
.downcast_ref::<ExecCell>()
|
||||
.is_some_and(|cell| cell.iter_calls().count() == 2)
|
||||
})
|
||||
.expect("adjacent completed reads share one exploration group");
|
||||
insta::assert_snapshot!(
|
||||
"completion_only_replay_exploration_group",
|
||||
format!(
|
||||
"legacy:\n{}\ndetailed:\n{}",
|
||||
lines_to_single_string(&exploration.display_lines(/*width*/ 40)),
|
||||
lines_to_single_string(&exploration.transcript_lines(/*width*/ 40)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_formatter_reasoning_matches_legacy_and_detailed_replay() {
|
||||
let mut snapshots = Vec::new();
|
||||
@@ -30,13 +198,8 @@ async fn snapshot_formatter_reasoning_matches_legacy_and_detailed_replay() {
|
||||
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();
|
||||
chat.replay_thread_item(item.clone(), "turn".to_string(), ReplayKind::ThreadSnapshot);
|
||||
let replayed = take_history_cells(&mut rx);
|
||||
assert_eq!((projected.len(), replayed.len()), (1, 1));
|
||||
assert!(
|
||||
projected[0]
|
||||
@@ -55,6 +218,27 @@ async fn snapshot_formatter_reasoning_matches_legacy_and_detailed_replay() {
|
||||
projected[0].display_lines(/*width*/ 80),
|
||||
lines_to_single_string(&projected[0].transcript_lines(/*width*/ 80)),
|
||||
));
|
||||
chat.replay_thread_turns(
|
||||
vec![AppServerTurn {
|
||||
items: vec![item],
|
||||
..app_server_turn(
|
||||
"turn",
|
||||
AppServerTurnStatus::Completed,
|
||||
/*duration_ms*/ None,
|
||||
/*error*/ None,
|
||||
)
|
||||
}],
|
||||
ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
let replayed = take_history_cells(&mut rx);
|
||||
let reasoning = replayed
|
||||
.iter()
|
||||
.find_map(|cell| {
|
||||
cell.as_any()
|
||||
.downcast_ref::<history_cell::ReasoningSummaryCell>()
|
||||
})
|
||||
.expect("initial replay retains reasoning for older-page joins");
|
||||
assert_eq!(reasoning.source_item_id(), Some("reasoning"));
|
||||
}
|
||||
insta::assert_snapshot!(snapshots.join("\n"));
|
||||
}
|
||||
@@ -77,3 +261,14 @@ fn raw_reasoning_keeps_its_own_heading() {
|
||||
Keep this heading and its details.
|
||||
");
|
||||
}
|
||||
|
||||
fn take_history_cells(
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> Vec<Box<dyn HistoryCell>> {
|
||||
std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1278,26 +1278,27 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replayed_in_progress_mcp_tool_call_stays_active() {
|
||||
async fn replayed_mcp_tool_call_stays_active_until_completion() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let _ = drain_insert_history(&mut rx);
|
||||
|
||||
let mut item = AppServerThreadItem::McpToolCall {
|
||||
id: "mcp-1".to_string(),
|
||||
server: "copilot-bridge".to_string(),
|
||||
tool: "copilot".to_string(),
|
||||
status: codex_app_server_protocol::McpToolCallStatus::InProgress,
|
||||
arguments: json!({"action": "wait"}),
|
||||
app_context: None,
|
||||
mcp_app_resource_uri: None,
|
||||
mcp_app_ui: None,
|
||||
plugin_id: None,
|
||||
read_only_hint: None,
|
||||
result: None,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
};
|
||||
chat.replay_thread_item(
|
||||
AppServerThreadItem::McpToolCall {
|
||||
id: "mcp-1".to_string(),
|
||||
server: "copilot-bridge".to_string(),
|
||||
tool: "copilot".to_string(),
|
||||
status: codex_app_server_protocol::McpToolCallStatus::InProgress,
|
||||
arguments: json!({"action": "wait"}),
|
||||
app_context: None,
|
||||
mcp_app_resource_uri: None,
|
||||
mcp_app_ui: None,
|
||||
plugin_id: None,
|
||||
read_only_hint: None,
|
||||
result: None,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
},
|
||||
item.clone(),
|
||||
"turn-1".to_string(),
|
||||
ReplayKind::ThreadSnapshot,
|
||||
);
|
||||
@@ -1306,6 +1307,17 @@ async fn replayed_in_progress_mcp_tool_call_stays_active() {
|
||||
let active = active_blob(&chat);
|
||||
assert!(active.contains("Calling"));
|
||||
assert!(!active.contains("MCP tool call completed without a result"));
|
||||
|
||||
if let AppServerThreadItem::McpToolCall { status, .. } = &mut item {
|
||||
*status = codex_app_server_protocol::McpToolCallStatus::Completed;
|
||||
}
|
||||
chat.on_mcp_tool_call_completed(item);
|
||||
assert!(chat.transcript.active_cell.is_none());
|
||||
let completed = drain_insert_history(&mut rx);
|
||||
assert_eq!(completed.len(), 1);
|
||||
assert!(
|
||||
lines_to_single_string(&completed[0]).contains("MCP tool call completed without a result")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/dynamic_activity_tests.rs
|
||||
assertion_line: 150
|
||||
expression: "format!(\"{buffer:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 64, height: 2 },
|
||||
content: [
|
||||
"• Interrupted test.lookup ",
|
||||
" └ Interrupted before this tool returned a result. ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Red, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 1, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 13, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 51, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/dynamic_activity_tests.rs
|
||||
expression: "lines_to_single_string(&outputs[0])"
|
||||
---
|
||||
• Failed test.lookup · 25ms
|
||||
└ Result for call-1
|
||||
• Called test.lookup · 25ms
|
||||
└ Result for call-2
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/group_projection_tests.rs
|
||||
expression: "format!(\"legacy:\\n{}\\ndetailed:\\n{}\",\nlines_to_single_string(&exploration.display_lines(40)),\nlines_to_single_string(&exploration.transcript_lines(40)),)"
|
||||
---
|
||||
legacy:
|
||||
• Explored
|
||||
└ Read one.rs, two.rs
|
||||
|
||||
detailed:
|
||||
$ cat one.rs
|
||||
fn main() {}
|
||||
✓ • 5ms
|
||||
|
||||
$ cat two.rs
|
||||
fn main() {}
|
||||
✓ • 5ms
|
||||
@@ -4,6 +4,7 @@
|
||||
//! events as transcript cells.
|
||||
|
||||
use super::*;
|
||||
use crate::thread_transcript::tools::McpHistory;
|
||||
use codex_utils_path_uri::LegacyAppPathString;
|
||||
|
||||
impl ChatWidget {
|
||||
@@ -199,39 +200,15 @@ impl ChatWidget {
|
||||
pub(crate) fn handle_mcp_tool_call_completed_now(&mut self, item: ThreadItem) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
|
||||
let ThreadItem::McpToolCall {
|
||||
let Some(McpHistory {
|
||||
id,
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
arguments,
|
||||
invocation,
|
||||
duration,
|
||||
result,
|
||||
error,
|
||||
duration_ms,
|
||||
..
|
||||
} = item
|
||||
}) = McpHistory::from_item(item)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let invocation = McpInvocation {
|
||||
server,
|
||||
tool,
|
||||
arguments: Some(arguments),
|
||||
};
|
||||
let duration = Duration::from_millis(duration_ms.unwrap_or_default().max(0) as u64);
|
||||
let result = match (result, error) {
|
||||
(_, Some(error)) => Err(error.message),
|
||||
(Some(result), None) => {
|
||||
let result = *result;
|
||||
Ok(codex_protocol::mcp::CallToolResult {
|
||||
content: result.content,
|
||||
structured_content: result.structured_content,
|
||||
is_error: Some(status == codex_app_server_protocol::McpToolCallStatus::Failed),
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
(None, None) => Err("MCP tool call completed without a result".to_string()),
|
||||
};
|
||||
|
||||
if invocation.is_computer_activity() {
|
||||
let call = history_cell::new_active_mcp_tool_call(
|
||||
@@ -293,6 +270,7 @@ impl ChatWidget {
|
||||
item @ ThreadItem::McpToolCall { .. } => {
|
||||
self.handle_mcp_tool_call_started_now(item);
|
||||
}
|
||||
item @ ThreadItem::DynamicToolCall { .. } => self.handle_dynamic_tool_item_now(item),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -304,6 +282,7 @@ impl ChatWidget {
|
||||
}
|
||||
item @ ThreadItem::FileChange { .. } => self.handle_file_change_completed_now(item),
|
||||
item @ ThreadItem::McpToolCall { .. } => self.handle_mcp_tool_call_completed_now(item),
|
||||
item @ ThreadItem::DynamicToolCall { .. } => self.handle_dynamic_tool_item_now(item),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ pub(super) struct StatusCopySource {
|
||||
#[derive(Default)]
|
||||
pub(super) struct TranscriptState {
|
||||
pub(super) active_cell: Option<Box<dyn HistoryCell>>,
|
||||
/// Shared retained rows for concurrently running dynamic tools, removed on completion.
|
||||
pub(super) dynamic_calls:
|
||||
std::collections::HashMap<String, crate::history_cell::DynamicToolCallCell>,
|
||||
/// Monotonic-ish counter used to invalidate transcript overlay caching.
|
||||
pub(super) active_cell_revision: u64,
|
||||
/// One bounded entry shared by layout and paint across unchanged active-cell frames.
|
||||
|
||||
@@ -143,7 +143,10 @@ impl ChatWidget {
|
||||
self.transcript.saw_copy_source_this_turn = false;
|
||||
// If a stream is currently active, finalize it.
|
||||
self.flush_answer_and_plan_streams();
|
||||
self.flush_interrupt_queue();
|
||||
self.finish_dynamic_activity();
|
||||
self.flush_unified_exec_wait_streak();
|
||||
self.flush_completed_tool_activity();
|
||||
if !from_replay {
|
||||
self.collect_runtime_metrics_delta();
|
||||
}
|
||||
@@ -310,6 +313,8 @@ impl ChatWidget {
|
||||
/// and should continue to drive the bottom-pane running indicator while it is in progress.
|
||||
pub(super) fn finalize_turn(&mut self) {
|
||||
self.flush_answer_and_plan_streams();
|
||||
self.flush_interrupt_queue();
|
||||
self.finish_dynamic_activity();
|
||||
if self.status_state.reasoning_resume_turn_id.is_some() {
|
||||
self.on_agent_reasoning_final();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ 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>>,
|
||||
}
|
||||
@@ -45,12 +44,10 @@ impl DynamicToolCallCell {
|
||||
})
|
||||
}
|
||||
|
||||
#[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()
|
||||
@@ -59,8 +56,6 @@ impl DynamicToolCallCell {
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -75,7 +70,6 @@ impl DynamicToolCallCell {
|
||||
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
|
||||
@@ -138,7 +132,6 @@ impl DynamicToolCallData {
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
fn is_active(&self) -> bool {
|
||||
matches!(self.status, DynamicToolCallStatus::InProgress)
|
||||
}
|
||||
@@ -181,7 +174,7 @@ impl DynamicToolCallData {
|
||||
|
||||
impl HistoryCell for DynamicToolCallCell {
|
||||
fn has_stable_transcript_height(&self) -> bool {
|
||||
!self.is_active()
|
||||
false
|
||||
}
|
||||
|
||||
fn activity_ids(&self) -> Vec<String> {
|
||||
|
||||
@@ -319,7 +319,6 @@ pub(crate) struct ReasoningSummaryCell {
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -332,12 +331,10 @@ impl ReasoningSummaryCell {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -172,7 +172,12 @@ impl TranscriptOverlay {
|
||||
let index = self
|
||||
.cells
|
||||
.iter()
|
||||
.rposition(|cell| cell.as_any().is::<SessionInfoCell>())
|
||||
.rposition(|cell| {
|
||||
cell.as_any().is::<SessionInfoCell>()
|
||||
|| cell
|
||||
.as_any()
|
||||
.is::<crate::history_cell::SessionHeaderHistoryCell>()
|
||||
})
|
||||
.map_or(/*default*/ 0, |index| index + 1);
|
||||
let added = cells.len();
|
||||
self.cells.splice(index..index, cells);
|
||||
|
||||
@@ -221,7 +221,7 @@ impl McpHistory {
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
(None, None) => return None,
|
||||
(None, None) => Err("MCP tool call completed without a result".to_string()),
|
||||
};
|
||||
Some(Self {
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user