Files
codex/codex-rs/tui/src/pager_overlay/transcript.rs
T
Eric Traut 6ba6b73657 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
2026-09-20 00:03:43 +00:00

363 lines
12 KiB
Rust

//! Detailed transcript overlay with entry-based scrolling and the existing pager chrome.
//!
//! The overlay owns committed cells and input bindings. Its viewport displays current content
//! and keeps readers on the same entry through pagination and stream consolidation.
use super::*;
use crate::transcript_view::TranscriptView;
pub(crate) struct TranscriptOverlay {
view: TranscriptView,
cells: Vec<Arc<dyn HistoryCell>>,
keymap: PagerKeymap,
highlight_cell: Option<usize>,
reveal_highlight: bool,
content_area: Rect,
is_done: bool,
}
impl TranscriptOverlay {
pub(crate) fn new(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self {
view: TranscriptView::default(),
cells,
keymap,
highlight_cell: None,
reveal_highlight: false,
content_area: Rect::default(),
is_done: false,
}
}
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let top_height = area.height.saturating_sub(/*rhs*/ 3);
let top = Rect::new(area.x, area.y, area.width, top_height);
self.content_area = Rect::new(
area.x,
area.y.saturating_add(/*rhs*/ 1),
area.width,
top_height.saturating_sub(/*rhs*/ 2),
)
.intersection(area);
let mut rendered =
self.view
.render(self.content_area, buf, &self.cells, self.highlight_cell);
if std::mem::take(&mut self.reveal_highlight)
&& let Some(index) = self.highlight_cell
{
self.view.ensure_entry_visible(&self.cells, index);
rendered = self
.view
.render(self.content_area, buf, &self.cells, self.highlight_cell);
}
if self.view.history == TranscriptHistoryState::LoadingBeginning
&& self.view.is_following()
&& !self.content_area.is_empty()
{
// Home may arrive before the first draw; retain the first real viewport.
self.view.scroll(&self.cells, /*rows*/ 0);
}
if area.width > 0 {
for y in self.content_area.y + rendered..self.content_area.bottom() {
"~".render(Rect::new(area.x, y, /*width*/ 1, /*height*/ 1), buf);
}
}
let header = Rect::new(area.x, area.y, area.width, area.height.min(/*other*/ 1));
Span::from("/ ".repeat(usize::from(area.width) / 2))
.dim()
.render(header, buf);
"/ T R A N S C R I P T".dim().render(header, buf);
self.render_history_state(top, buf);
let separator = Rect::new(
area.x,
self.content_area.bottom(),
area.width,
/*height*/ 1,
)
.intersection(area);
"─"
.repeat(usize::from(separator.width))
.dim()
.render(separator, buf);
// An exact intermediate percentage would require laying out every offscreen cell.
// Only the loaded tail has a known percentage without defeating bounded rendering.
if self.view.is_following()
&& !self.view.history.has_unloaded_history()
&& separator.width >= 7
{
" 100% ".dim().render(
Rect::new(
separator.right() - 7,
separator.y,
/*width*/ 6,
separator.height,
),
buf,
);
}
let footer =
Rect::new(area.x, area.y + top_height, area.width, /*height*/ 3).intersection(area);
self.render_hints(footer, buf);
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key) => {
if self.keymap.close.is_pressed(key) || self.keymap.close_transcript.is_pressed(key)
{
self.is_done = true;
} else if self.navigate(key) {
tui.frame_requester()
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
}
Ok(())
}
TuiEvent::Draw | TuiEvent::Resume | TuiEvent::Resize(_) | TuiEvent::FocusGained => {
tui.draw(u16::MAX, |frame| self.render(frame.area(), frame.buffer))?;
Ok(())
}
_ => Ok(()),
}
}
pub(crate) fn set_history_state(
&mut self,
state: TranscriptHistoryState,
) -> TranscriptHistoryState {
let previous = std::mem::replace(&mut self.view.history, state);
if state == TranscriptHistoryState::LoadingBeginning
&& previous != TranscriptHistoryState::LoadingBeginning
&& !self.content_area.is_empty()
{
// App marks loading before forwarding Home, so record the current position here.
self.view.scroll(&self.cells, /*rows*/ 0);
}
if previous == TranscriptHistoryState::LoadingBeginning
&& state == TranscriptHistoryState::Complete
{
self.view.jump_to_entry(&self.cells, /*index*/ 0);
}
previous
}
pub(crate) fn should_load_older(&mut self, key: KeyEvent) -> bool {
self.should_load_from_start(key)
|| (self.view.near_start(&self.cells)
&& [
&self.keymap.scroll_up,
&self.keymap.page_up,
&self.keymap.half_page_up,
]
.iter()
.any(|bindings| bindings.is_pressed(key)))
}
pub(crate) fn should_load_from_start(&self, key: KeyEvent) -> bool {
self.keymap.jump_top.is_pressed(key)
}
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
let index = self.cells.len();
self.view
.replace_range(&self.cells, index..index + 1, &cell);
self.cells.push(cell);
}
/// Prepends history and returns the insertion index for the canonical transcript.
pub(crate) fn prepend(&mut self, cells: Vec<Arc<dyn HistoryCell>>) -> usize {
if cells.is_empty() {
return 0;
}
let index = self
.cells
.iter()
.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);
if let Some(highlight) = self
.highlight_cell
.as_mut()
.filter(|highlight| **highlight >= index)
{
*highlight += added;
}
self.view.history_changed(&self.cells);
index
}
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
self.cells = cells;
self.view.history_changed(&self.cells);
self.highlight_cell = self
.highlight_cell
.filter(|index| *index < self.cells.len());
}
pub(crate) fn consolidate_cells(
&mut self,
range: std::ops::Range<usize>,
consolidated: Arc<dyn HistoryCell>,
) {
let end = range.end.min(self.cells.len());
let start = range.start.min(end);
if start == end {
return;
}
self.view
.replace_range(&self.cells, start..end, &consolidated);
self.highlight_cell = self.highlight_cell.map(|index| {
if index < start {
index
} else if index < end {
start
} else {
index - (end - start - 1)
}
});
self.cells.splice(start..end, [consolidated]);
}
pub(crate) fn sync_live_tail(
&mut self,
width: u16,
key: Option<ActiveCellTranscriptKey>,
compute_lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
) {
self.view.sync_live_tail(width, key, compute_lines);
}
/// Explicit prompt navigation supersedes Home; passive highlight restoration does not.
pub(crate) fn cancel_pending_jump(&mut self) {
self.view.cancel_beginning();
}
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell.filter(|index| *index < self.cells.len());
self.reveal_highlight = self.highlight_cell.is_some();
}
pub(crate) fn scroll(&mut self, rows: isize) {
if std::mem::take(&mut self.reveal_highlight)
&& let Some(index) = self.highlight_cell
{
self.view.ensure_entry_visible(&self.cells, index);
}
self.view.scroll(&self.cells, rows);
}
fn navigate(&mut self, key: KeyEvent) -> bool {
if self.keymap.jump_top.is_pressed(key) {
if self.content_area.is_empty() && self.view.history.has_unloaded_history() {
self.set_history_state(TranscriptHistoryState::LoadingBeginning);
} else {
self.view.jump_to_beginning(&self.cells);
}
return true;
}
if self.keymap.jump_bottom.is_pressed(key) {
self.view.jump_to_latest();
return true;
}
let page = self.content_area.height.max(/*other*/ 1) as isize;
let half = (page + 1) / 2;
let delta = [
(&self.keymap.scroll_up, -1),
(&self.keymap.scroll_down, 1),
(&self.keymap.page_up, -page),
(&self.keymap.page_down, page),
(&self.keymap.half_page_up, -half),
(&self.keymap.half_page_down, half),
]
.into_iter()
.find_map(|(bindings, delta)| bindings.is_pressed(key).then_some(delta));
let Some(delta) = delta else {
return false;
};
self.view.cancel_beginning();
if !self.content_area.is_empty() {
self.scroll(delta);
}
true
}
pub(crate) fn live_tail_visible(&self) -> bool {
self.view.live_tail_visible()
}
pub(crate) fn is_done(&self) -> bool {
self.is_done
}
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, /*height*/ 1).intersection(area);
let line2 = Rect::new(
area.x,
area.y.saturating_add(/*rhs*/ 1),
area.width,
/*height*/ 1,
)
.intersection(area);
render_navigation_hints(line1, buf, &self.keymap);
let mut pairs: Vec<(Vec<ShortcutHint>, &str)> = vec![(
first_or_empty(&self.keymap, "close", &self.keymap.close),
"close",
)];
if self.highlight_cell.is_some() {
pairs.push((
vec![
key_hint::plain(KeyCode::Esc).into(),
key_hint::plain(KeyCode::Left).into(),
],
"to edit prev",
));
pairs.push((vec![key_hint::plain(KeyCode::Right).into()], "to edit next"));
pairs.push((
vec![key_hint::plain(KeyCode::Enter).into()],
"to edit message",
));
} else {
pairs.push((vec![key_hint::plain(KeyCode::Esc).into()], "to edit prev"));
}
render_key_hints(line2, buf, &pairs);
}
fn render_history_state(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let label = match self.view.history {
TranscriptHistoryState::Idle => return,
TranscriptHistoryState::LoadingOlder | TranscriptHistoryState::LoadingBeginning => {
" loading older history... "
}
TranscriptHistoryState::Partial => " partial history | PgUp for earlier ",
TranscriptHistoryState::Failed => " history unavailable | PgUp to retry ",
TranscriptHistoryState::Complete => " start of history ",
};
let width = (label.chars().count() as u16).min(area.width);
let status_area = Rect::new(
area.right().saturating_sub(width),
area.y,
width,
/*height*/ 1,
);
Span::from(label).dim().render(status_area, buf);
}
}
#[cfg(test)]
#[path = "transcript_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "anchor_tests.rs"]
mod anchor_tests;