fix(tui): show in-flight coalesced tool calls in transcript overlay (#8246)

### Problem
Ctrl+T transcript overlay can omit in-flight coalesced tool calls because it
renders only committed transcript cells while the main viewport can render the
current in-flight ChatWidget.active_cell immediately.

### Mental model
The UI has both committed transcript cells (finalized HistoryCell entries) and
an in-flight active cell that can mutate in place while streaming, often
representing a coalesced exec/tool group. The transcript overlay renders
committed cells plus a render-only live tail derived from the current active
cell. The live tail is cached and only recomputed when its cache key changes,
which is derived from terminal width (wrapping), active-cell revision
(in-place mutations), stream continuation (spacing), and animation tick
(time-based visuals).

### Non-goals
This does not change coalescing rules, flush boundaries, or when active cells
become committed. It does not change tool-call semantics or transcript
persistence; it is a rendering-only improvement for the overlay.

### Tradeoffs
This adds cache invalidation complexity: correctness depends on bumping an
active-cell revision (and/or providing an animation tick) when the active cell
mutates in place. The mechanism is implemented in both codex-tui and codex-tui2,
which keeps behavior consistent but risks drift if future changes are not
applied in lockstep.

### Architecture
App special-cases transcript overlay draws to sync a live tail from ChatWidget
into TranscriptOverlay. TranscriptOverlay remains the owner of committed
transcript cells; the live tail is an optional appended renderable.
HistoryCell::transcript_animation_tick() allows time-dependent transcript output
(spinner/shimmer) to invalidate the cached tail without requiring data mutation.

### Observability
Manual verification is to open Ctrl+T while an exploring/coalesced active cell
is still in-flight and confirm the overlay includes the same in-flight tool-call
group the main viewport shows. The overlay is kept in sync by App passing an
active-cell key and transcript lines into TranscriptOverlay::sync_live_tail; the
key must change when the active cell mutates or animates.

### Tests
Snapshot tests validate that the transcript overlay renders a live tail appended
after committed cells and that identical keys short-circuit recomputation. Unit
tests validate that active-cell revision bumps occur on specific in-place
mutations (e.g. unified exec wait cell command display becoming known late) so
cached tails are invalidated.

## Documentation patches (module, type, function)

### Module-level docs (invariants + mechanisms)
- codex-rs/tui/src/app_backtrack.rs:1
- codex-rs/tui/src/chatwidget.rs:1
- codex-rs/tui/src/pager_overlay.rs:1
- codex-rs/tui/src/history_cell.rs:1
- codex-rs/tui2/src/app_backtrack.rs:1
- codex-rs/tui2/src/chatwidget.rs:1
- codex-rs/tui2/src/pager_overlay.rs:1
- codex-rs/tui2/src/history_cell.rs:1

### Type-level docs (cache key + invariants)
- codex-rs/tui/src/chatwidget.rs (ChatWidget.active_cell_revision, ActiveCellTranscriptKey)
- codex-rs/tui/src/pager_overlay.rs (TranscriptOverlay live tail storage model)
- codex-rs/tui/src/history_cell.rs (HistoryCell::transcript_animation_tick, UnifiedExecWaitCell::update_command_display)
- Mirrored in codex-rs/tui2/src/chatwidget.rs, codex-rs/tui2/src/pager_overlay.rs, codex-rs/tui2/src/history_cell.rs

### Function-level docs (why/when/guarantees/pitfalls)
- codex-rs/tui/src/app_backtrack.rs (overlay_forward_event)
- codex-rs/tui/src/chatwidget.rs (active_cell_transcript_key, active_cell_transcript_lines)
- codex-rs/tui/src/pager_overlay.rs (sync_live_tail, take_live_tail_renderable)
- codex-rs/tui/src/history_cell.rs (transcript_animation_tick, UnifiedExecWaitCell::update_command_display)
- Mirrored in codex-rs/tui2 equivalents where present

### Validation performed
- cd codex-rs && just fmt
- cd codex-rs && cargo test -p codex-tui
- cd codex-rs && cargo test -p codex-tui2

## Design inconsistencies / risks

- Cache invalidation is a distributed responsibility: any future in-place active
  cell transcript mutation that forgets to bump active_cell_revision (or expose
  an animation tick) can leave the transcript overlay live tail out of sync with
  the main viewport.
- TranscriptOverlay tail handling assumes a structural invariant that the live
  tail, when present, is exactly one trailing renderable after the committed cell
  renderables; if renderable construction changes in a way that violates that
  assumption, tail insertion/removal logic becomes incorrect.
- codex-tui and codex-tui2 duplicate the live-tail mechanism; the documentation
  is aligned, but the implementation can still drift unless changes continue to
  be applied in lockstep.
This commit is contained in:
Chriss4123
2026-01-13 05:06:11 +02:00
committed by GitHub
Unverified
parent 490c1c1fdd
commit 12779c7c07
12 changed files with 896 additions and 27 deletions
+53 -1
View File
@@ -1,3 +1,16 @@
//! Backtracking and transcript overlay event routing.
//!
//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also
//! mediates a key rendering boundary for the transcript overlay.
//!
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
//! tail derived from the current in-flight `ChatWidget.active_cell`.
//!
//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by
//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into
//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects
//! both committed history and in-flight activity without changing flush or coalescing behavior.
use std::any::TypeId;
use std::path::PathBuf;
use std::sync::Arc;
@@ -216,8 +229,47 @@ impl App {
}
}
/// Forward any event to the overlay and close it if done.
/// Forwards an event to the overlay and closes it if done.
///
/// The transcript overlay draw path is special because the overlay should match the main
/// viewport while the active cell is still streaming or mutating.
///
/// `TranscriptOverlay` owns committed transcript cells, while `ChatWidget` owns the current
/// in-flight active cell (often a coalesced exec/tool group). During draws we append that
/// in-flight cell as a cached, render-only live tail so `Ctrl+T` does not appear to "lose" tool
/// calls until a later flush boundary.
///
/// This logic lives here (instead of inside the overlay widget) because `ChatWidget` is the
/// source of truth for the active cell and its cache invalidation key, and because `App` owns
/// overlay lifecycle and frame scheduling for animations.
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
if let TuiEvent::Draw = &event
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
{
let active_key = self.chat_widget.active_cell_transcript_key();
let chat_widget = &self.chat_widget;
tui.draw(u16::MAX, |frame| {
let width = frame.area().width.max(1);
t.sync_live_tail(width, active_key, |w| {
chat_widget.active_cell_transcript_lines(w)
});
t.render(frame.area(), frame.buffer);
})?;
let close_overlay = t.is_done();
if !close_overlay
&& active_key.is_some_and(|key| key.animation_tick.is_some())
&& t.is_scrolled_to_bottom()
{
tui.frame_requester()
.schedule_frame_in(std::time::Duration::from_millis(50));
}
if close_overlay {
self.close_transcript_overlay(tui);
tui.frame_requester().schedule_frame();
}
return Ok(());
}
if let Some(overlay) = &mut self.overlay {
overlay.handle_event(tui, event)?;
if overlay.is_done() {
+100 -1
View File
@@ -1,3 +1,20 @@
//! The main Codex TUI chat surface.
//!
//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering
//! for both the main viewport and overlay UIs.
//!
//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active
//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a
//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a
//! cached, render-only live tail derived from the current active cell so in-flight tool calls are
//! visible immediately.
//!
//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail
//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The
//! cache key is designed to change when the active cell mutates in place or when its transcript
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
//! every draw.
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
@@ -318,6 +335,16 @@ pub(crate) struct ChatWidget {
codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane,
active_cell: Option<Box<dyn HistoryCell>>,
/// Monotonic-ish counter used to invalidate transcript overlay caching.
///
/// The transcript overlay appends a cached "live tail" for the current active cell. Most
/// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer
/// identity alone is not a good cache key.
///
/// Callers bump this whenever the active cell's transcript output could change without
/// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision
/// where the overlay may briefly treat new tail content as already cached.
active_cell_revision: u64,
config: Config,
model: String,
auth_manager: Arc<AuthManager>,
@@ -374,6 +401,30 @@ pub(crate) struct ChatWidget {
external_editor_state: ExternalEditorState,
}
/// Snapshot of active-cell state that affects transcript overlay rendering.
///
/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets
/// it cheaply decide when to recompute that tail as the active cell evolves.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ActiveCellTranscriptKey {
/// Cache-busting revision for in-place updates.
///
/// Many active cells are updated incrementally while streaming (for example when exec groups
/// add output or change status), and the transcript overlay caches its live tail, so this
/// revision gives a cheap way to say "same active cell, but its transcript output is different
/// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`.
pub(crate) revision: u64,
/// Whether the active cell continues the prior stream, which affects
/// spacing between transcript blocks.
pub(crate) is_stream_continuation: bool,
/// Optional animation tick for time-dependent transcript output.
///
/// When this changes, the overlay recomputes the cached tail even if the revision and width
/// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any
/// underlying data change.
pub(crate) animation_tick: Option<u64>,
}
struct UserMessage {
text: String,
image_paths: Vec<PathBuf>,
@@ -903,7 +954,9 @@ impl ChatWidget {
}) && wait_cell.matches(command_display.as_deref())
{
// Same process still waiting; update command display if it shows up late.
wait_cell.update_command_display(command_display);
if wait_cell.update_command_display(command_display) {
self.bump_active_cell_revision();
}
self.request_redraw();
return;
}
@@ -924,6 +977,7 @@ impl ChatWidget {
command_display,
self.config.animations,
)));
self.bump_active_cell_revision();
self.request_redraw();
} else {
if let Some(wait_cell) = self.active_cell.as_ref().and_then(|cell| {
@@ -1228,6 +1282,9 @@ impl ChatWidget {
cell.complete_call(&ev.call_id, output, ev.duration);
if cell.should_flush() {
self.flush_active_cell();
} else {
self.bump_active_cell_revision();
self.request_redraw();
}
}
}
@@ -1344,6 +1401,7 @@ impl ChatWidget {
)
{
*cell = new_exec;
self.bump_active_cell_revision();
} else {
self.flush_active_cell();
@@ -1355,6 +1413,7 @@ impl ChatWidget {
interaction_input,
self.config.animations,
)));
self.bump_active_cell_revision();
}
self.request_redraw();
@@ -1368,6 +1427,7 @@ impl ChatWidget {
ev.invocation,
self.config.animations,
)));
self.bump_active_cell_revision();
self.request_redraw();
}
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
@@ -1440,6 +1500,7 @@ impl ChatWidget {
skills: None,
}),
active_cell: None,
active_cell_revision: 0,
config,
model: model.clone(),
auth_manager,
@@ -1526,6 +1587,7 @@ impl ChatWidget {
skills: None,
}),
active_cell: None,
active_cell_revision: 0,
config,
model: model.clone(),
auth_manager,
@@ -2259,6 +2321,12 @@ impl ChatWidget {
self.frame_requester.schedule_frame();
}
fn bump_active_cell_revision(&mut self) {
// Wrapping avoids overflow; wraparound would require 2^64 bumps and at
// worst causes a one-time cache-key collision.
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
}
fn notify(&mut self, notification: Notification) {
if !notification.allowed_for(&self.config.tui_notifications) {
return;
@@ -3876,6 +3944,37 @@ impl ChatWidget {
self.current_rollout_path.clone()
}
/// Returns a cache key describing the current in-flight active cell for the transcript overlay.
///
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
/// current active cell, and the overlay caches that tail; this key is what it uses to decide
/// whether it must recompute. When there is no active cell, this returns `None` so the overlay
/// can drop the tail entirely.
///
/// If callers mutate the active cell's transcript output without bumping the revision (or
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
/// the main viewport updates.
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
let cell = self.active_cell.as_ref()?;
Some(ActiveCellTranscriptKey {
revision: self.active_cell_revision,
is_stream_continuation: cell.is_stream_continuation(),
animation_tick: cell.transcript_animation_tick(),
})
}
/// Returns the active cell's transcript lines for a given terminal width.
///
/// This is a convenience for the transcript overlay live-tail path, and it intentionally
/// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers
/// should pass the same width the overlay uses; using a different width will cause wrapping
/// mismatches between the main viewport and the transcript overlay.
pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option<Vec<Line<'static>>> {
let cell = self.active_cell.as_ref()?;
let lines = cell.transcript_lines(width);
(!lines.is_empty()).then_some(lines)
}
/// Return a reference to the widget's current config (includes any
/// runtime overrides applied via TUI, e.g., model or approval policy).
pub(crate) fn config_ref(&self) -> &Config {
+61
View File
@@ -389,6 +389,7 @@ async fn make_chatwidget_manual(
codex_op_tx: op_tx,
bottom_pane: bottom,
active_cell: None,
active_cell_revision: 0,
config: cfg,
model: resolved_model.clone(),
auth_manager: auth_manager.clone(),
@@ -1306,6 +1307,66 @@ async fn unified_exec_end_after_task_complete_is_suppressed() {
);
}
#[tokio::test]
async fn unified_exec_wait_cell_revision_updates_on_late_command_display() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.active_cell = Some(Box::new(crate::history_cell::new_unified_exec_wait_live(
None,
chat.config.animations,
)));
chat.unified_exec_processes.push(UnifiedExecProcessSummary {
key: "proc-1".to_string(),
command_display: "sleep 5".to_string(),
});
let before = chat.active_cell_revision;
chat.on_terminal_interaction(TerminalInteractionEvent {
call_id: "call-1".to_string(),
process_id: "proc-1".to_string(),
stdin: String::new(),
});
assert_eq!(chat.active_cell_revision, before.wrapping_add(1));
let lines = chat
.active_cell_transcript_lines(80)
.expect("active cell lines");
let blob = lines_to_single_string(&lines);
assert!(
blob.contains("sleep 5"),
"expected command display to render: {blob:?}"
);
}
#[tokio::test]
async fn unified_exec_wait_cell_revision_updates_on_replacement() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.active_cell = Some(Box::new(crate::history_cell::new_unified_exec_wait_live(
Some("old command".to_string()),
chat.config.animations,
)));
chat.unified_exec_processes.push(UnifiedExecProcessSummary {
key: "proc-2".to_string(),
command_display: "new command".to_string(),
});
let before = chat.active_cell_revision;
chat.on_terminal_interaction(TerminalInteractionEvent {
call_id: "call-2".to_string(),
process_id: "proc-2".to_string(),
stdin: String::new(),
});
assert_eq!(chat.active_cell_revision, before.wrapping_add(1));
let lines = chat
.active_cell_transcript_lines(80)
.expect("active cell lines");
let blob = lines_to_single_string(&lines);
assert!(
blob.contains("new command"),
"expected replacement wait cell to render: {blob:?}"
);
}
#[tokio::test]
async fn unified_exec_waiting_multiple_empty_snapshots() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
+55 -3
View File
@@ -1,3 +1,15 @@
//! Transcript/history cells for the Codex TUI.
//!
//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed
//! transcript entries and, transiently, an in-flight active cell that can mutate in place while
//! streaming.
//!
//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and
//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on
//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
//! rendered transcript output can change.
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -100,6 +112,20 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
fn is_stream_continuation(&self) -> bool {
false
}
/// Returns a coarse "animation tick" when transcript output is time-dependent.
///
/// The transcript overlay caches the rendered output of the in-flight active cell, so cells
/// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over
/// time to signal that the cached tail should be recomputed. Returning `None` means the
/// transcript lines are stable, while returning `Some(tick)` during an in-flight animation
/// allows the overlay to keep up with the main viewport.
///
/// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on
/// the first rendered frame even though the main viewport is animating.
fn transcript_animation_tick(&self) -> Option<u64> {
None
}
}
impl Renderable for Box<dyn HistoryCell> {
@@ -448,6 +474,7 @@ pub(crate) fn new_unified_exec_interaction(
pub(crate) struct UnifiedExecWaitCell {
command_display: Option<String>,
animations_enabled: bool,
start_time: Instant,
}
impl UnifiedExecWaitCell {
@@ -455,6 +482,7 @@ impl UnifiedExecWaitCell {
Self {
command_display: command_display.filter(|display| !display.is_empty()),
animations_enabled,
start_time: Instant::now(),
}
}
@@ -466,10 +494,19 @@ impl UnifiedExecWaitCell {
}
}
pub(crate) fn update_command_display(&mut self, command_display: Option<String>) {
if self.command_display.is_none() {
self.command_display = command_display.filter(|display| !display.is_empty());
/// Update the command display once.
///
/// Unified exec can start without a stable command string, and later correlate a process id to
/// a user-facing `command_display`. This method records that first non-empty command display and
/// returns whether it changed the cell; callers use the `true` case to invalidate any cached
/// transcript rendering (for example, the transcript overlay live tail).
pub(crate) fn update_command_display(&mut self, command_display: Option<String>) -> bool {
let command_display = command_display.filter(|display| !display.is_empty());
if self.command_display.is_some() || command_display.is_none() {
return false;
}
self.command_display = command_display;
true
}
pub(crate) fn command_display(&self) -> Option<String> {
@@ -507,6 +544,14 @@ impl HistoryCell for UnifiedExecWaitCell {
fn desired_height(&self, width: u16) -> u16 {
self.display_lines(width).len() as u16
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled {
return None;
}
// Match `App`'s frame scheduling cadence for transcript overlay live-tail animation.
Some((self.start_time.elapsed().as_millis() / 50) as u64)
}
}
pub(crate) fn new_unified_exec_wait_live(
@@ -1252,6 +1297,13 @@ impl HistoryCell for McpToolCallCell {
lines
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled || self.result.is_some() {
return None;
}
Some((self.start_time.elapsed().as_millis() / 50) as u64)
}
}
pub(crate) fn new_active_mcp_tool_call(
+206 -1
View File
@@ -1,7 +1,25 @@
//! Overlay UIs rendered in an alternate screen.
//!
//! This module implements the pager-style overlays used by the TUI, including the transcript
//! overlay (`Ctrl+T`) that renders a full history view separate from the main viewport.
//!
//! The transcript overlay renders committed transcript cells plus an optional render-only live tail
//! derived from the current in-flight active cell. Because rebuilding wrapped `Line`s on every draw
//! can be expensive, that live tail is cached and only recomputed when its cache key changes, which
//! is derived from the terminal width (wrapping), an active-cell revision (in-place mutations), the
//! stream-continuation flag (spacing), and an animation tick (time-based spinner/shimmer output).
//!
//! The transcript overlay live tail is kept in sync by `App` during draws: `App` supplies an
//! `ActiveCellTranscriptKey` and a function to compute the active cell transcript lines, and
//! `TranscriptOverlay::sync_live_tail` uses the key to decide when the cached tail must be
//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell
//! mutates in place or when its transcript output is time-dependent.
use std::io::Result;
use std::sync::Arc;
use std::time::Duration;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint;
@@ -401,13 +419,39 @@ impl Renderable for CellRenderable {
}
pub(crate) struct TranscriptOverlay {
/// Pager UI state and the renderables currently displayed.
///
/// The invariant is that `view.renderables` is `render_cells(cells)` plus an optional trailing
/// live-tail renderable appended after the committed cells.
view: PagerView,
/// Committed transcript cells (does not include the live tail).
cells: Vec<Arc<dyn HistoryCell>>,
highlight_cell: Option<usize>,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
is_done: bool,
}
/// Cache key for the active-cell "live tail" appended to the transcript overlay.
///
/// Changing any field implies a different rendered tail.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct LiveTailKey {
/// Current terminal width, which affects wrapping.
width: u16,
/// Revision that changes on in-place active cell transcript updates.
revision: u64,
/// Whether the tail should be treated as a continuation for spacing.
is_stream_continuation: bool,
/// Optional animation tick to refresh spinners/progress indicators.
animation_tick: Option<u64>,
}
impl TranscriptOverlay {
/// Creates a transcript overlay for a fixed set of committed cells.
///
/// This overlay does not own the "active cell"; callers may optionally append a live tail via
/// `sync_live_tail` during draws to reflect in-flight activity.
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
Self {
view: PagerView::new(
@@ -417,6 +461,7 @@ impl TranscriptOverlay {
),
cells: transcript_cells,
highlight_cell: None,
live_tail_key: None,
is_done: false,
}
}
@@ -457,10 +502,85 @@ impl TranscriptOverlay {
.collect()
}
/// Insert a committed history cell while keeping any cached live tail.
///
/// The live tail is temporarily removed, the committed cells are rebuilt,
/// then the tail is reattached. If the tail previously had no leading
/// spacing because it was the only renderable, we add the missing inset
/// when the first committed cell arrives.
///
/// This expects `cell` to be a committed transcript cell (not the in-flight active cell). If
/// the overlay was scrolled to bottom before insertion, it remains pinned to bottom after the
/// insertion to preserve the "follow along" behavior.
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
let follow_bottom = self.view.is_scrolled_to_bottom();
let had_prior_cells = !self.cells.is_empty();
let tail_renderable = self.take_live_tail_renderable();
self.cells.push(cell);
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
if let Some(tail) = tail_renderable {
let tail = if !had_prior_cells
&& self
.live_tail_key
.is_some_and(|key| !key.is_stream_continuation)
{
// The tail was rendered as the only entry, so it lacks a top
// inset; add one now that it follows a committed cell.
Box::new(InsetRenderable::new(tail, Insets::tlbr(1, 0, 0, 0)))
as Box<dyn Renderable>
} else {
tail
};
self.view.renderables.push(tail);
}
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
}
/// Sync the active-cell live tail with the current width and cell state.
///
/// Recomputes the tail only when the cache key changes, preserving scroll
/// position and dropping the tail if there is nothing to render.
///
/// The overlay owns committed transcript cells while the live tail is derived from the current
/// active cell, which can mutate in place while streaming. `App` calls this during
/// `TuiEvent::Draw` for `Overlay::Transcript`, passing a key that changes when the active cell
/// mutates or animates so the cached tail stays fresh.
///
/// Passing a key that does not change on in-place active-cell mutations will freeze the tail in
/// `Ctrl+T` while the main viewport continues to update.
pub(crate) fn sync_live_tail(
&mut self,
width: u16,
active_key: Option<ActiveCellTranscriptKey>,
compute_lines: impl FnOnce(u16) -> Option<Vec<Line<'static>>>,
) {
let next_key = active_key.map(|key| LiveTailKey {
width,
revision: key.revision,
is_stream_continuation: key.is_stream_continuation,
animation_tick: key.animation_tick,
});
if self.live_tail_key == next_key {
return;
}
let follow_bottom = self.view.is_scrolled_to_bottom();
self.take_live_tail_renderable();
self.live_tail_key = next_key;
if let Some(key) = next_key {
let lines = compute_lines(width).unwrap_or_default();
if !lines.is_empty() {
self.view.renderables.push(Self::live_tail_renderable(
lines,
!self.cells.is_empty(),
key.is_stream_continuation,
));
}
}
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
@@ -468,12 +588,50 @@ impl TranscriptOverlay {
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell;
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
self.rebuild_renderables();
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
}
}
/// Returns whether the underlying pager view is currently pinned to the bottom.
///
/// The `App` draw loop uses this to decide whether to schedule animation frames for the live
/// tail; if the user has scrolled up, we avoid driving animation work that they cannot see.
pub(crate) fn is_scrolled_to_bottom(&self) -> bool {
self.view.is_scrolled_to_bottom()
}
fn rebuild_renderables(&mut self) {
let tail_renderable = self.take_live_tail_renderable();
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
if let Some(tail) = tail_renderable {
self.view.renderables.push(tail);
}
}
/// Removes and returns the cached live-tail renderable, if present.
///
/// The live tail is represented as a single optional renderable appended after the committed
/// cell renderables, so this relies on the live tail always being the final entry in
/// `view.renderables` when present.
fn take_live_tail_renderable(&mut self) -> Option<Box<dyn Renderable>> {
(self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())?
}
fn live_tail_renderable(
lines: Vec<Line<'static>>,
has_prior_cells: bool,
is_stream_continuation: bool,
) -> Box<dyn Renderable> {
let paragraph = Paragraph::new(Text::from(lines));
let mut renderable: Box<dyn Renderable> = Box::new(CachedRenderable::new(paragraph));
if has_prior_cells && !is_stream_continuation {
renderable = Box::new(InsetRenderable::new(renderable, Insets::tlbr(1, 0, 0, 0)));
}
renderable
}
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
@@ -612,6 +770,7 @@ mod tests {
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ReviewDecision;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@@ -696,6 +855,52 @@ mod tests {
assert_snapshot!(term.backend());
}
#[test]
fn transcript_overlay_renders_live_tail() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
overlay.sync_live_tail(
40,
Some(ActiveCellTranscriptKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![Line::from("tail")]),
);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(term.backend());
}
#[test]
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
let calls = std::cell::Cell::new(0usize);
let key = ActiveCellTranscriptKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
};
overlay.sync_live_tail(40, Some(key), |_| {
calls.set(calls.get() + 1);
Some(vec![Line::from("tail")])
});
overlay.sync_live_tail(40, Some(key), |_| {
calls.set(calls.get() + 1);
Some(vec![Line::from("tail2")])
});
assert_eq!(calls.get(), 1);
}
fn buffer_to_text(buf: &Buffer, area: Rect) -> String {
let mut out = String::new();
for y in area.y..area.bottom() {
@@ -0,0 +1,14 @@
---
source: tui/src/pager_overlay.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "
"alpha "
" "
"tail "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
" "
+44
View File
@@ -1,3 +1,16 @@
//! Backtracking and transcript overlay event routing.
//!
//! This file owns backtrack mode (Esc/Enter navigation in the transcript overlay) and also
//! mediates a key rendering boundary for the transcript overlay.
//!
//! The transcript overlay (`Ctrl+T`) renders committed transcript cells plus a render-only live
//! tail derived from the current in-flight `ChatWidget.active_cell`.
//!
//! That live tail is kept in sync during `TuiEvent::Draw` handling for `Overlay::Transcript` by
//! asking `ChatWidget` for an active-cell cache key and transcript lines and by passing them into
//! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects
//! both committed history and in-flight activity without changing flush or coalescing behavior.
use std::any::TypeId;
use std::path::PathBuf;
use std::sync::Arc;
@@ -248,6 +261,37 @@ impl App {
/// Forward any event to the overlay and close it if done.
fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
// Transcript overlay draws are special: include a live, in-flight tail so the
// overlay matches the main viewport while the active cell is still streaming.
// This path also drives tail animations and closes the overlay immediately
// once it reports completion.
if let TuiEvent::Draw = &event
&& let Some(Overlay::Transcript(t)) = &mut self.overlay
{
let active_key = self.chat_widget.active_cell_transcript_key();
let chat_widget = &self.chat_widget;
tui.draw(u16::MAX, |frame| {
let width = frame.area().width.max(1);
t.sync_live_tail(width, active_key, |w| {
chat_widget.active_cell_transcript_lines(w)
});
t.render(frame.area(), frame.buffer);
})?;
let close_overlay = t.is_done();
if !close_overlay
&& active_key.is_some_and(|key| key.animation_tick.is_some())
&& t.is_scrolled_to_bottom()
{
tui.frame_requester()
.schedule_frame_in(std::time::Duration::from_millis(50));
}
if close_overlay {
self.close_transcript_overlay(tui);
tui.frame_requester().schedule_frame();
}
return Ok(());
}
if let Some(overlay) = &mut self.overlay {
overlay.handle_event(tui, event)?;
if overlay.is_done() {
+96
View File
@@ -1,3 +1,20 @@
//! The main Codex TUI chat surface.
//!
//! `ChatWidget` consumes protocol events, builds and updates history cells, and drives rendering
//! for both the main viewport and overlay UIs.
//!
//! The UI has both committed transcript cells (finalized `HistoryCell`s) and an in-flight active
//! cell (`ChatWidget.active_cell`) that can mutate in place while streaming (often representing a
//! coalesced exec/tool group). The transcript overlay (`Ctrl+T`) renders committed cells plus a
//! cached, render-only live tail derived from the current active cell so in-flight tool calls are
//! visible immediately.
//!
//! The transcript overlay is kept in sync by `App::overlay_forward_event`, which syncs a live tail
//! during draws using `active_cell_transcript_key()` and `active_cell_transcript_lines()`. The
//! cache key is designed to change when the active cell mutates in place or when its transcript
//! output is time-dependent so the overlay can refresh its cached tail without rebuilding it on
//! every draw.
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
@@ -286,6 +303,16 @@ pub(crate) struct ChatWidget {
codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane,
active_cell: Option<Box<dyn HistoryCell>>,
/// Monotonic-ish counter used to invalidate transcript overlay caching.
///
/// The transcript overlay appends a cached "live tail" for the current active cell. Most
/// active-cell updates are mutations of the *existing* cell (not a replacement), so pointer
/// identity alone is not a good cache key.
///
/// Callers bump this whenever the active cell's transcript output could change without
/// flushing. It is intentionally allowed to wrap, which implies a rare one-time cache collision
/// where the overlay may briefly treat new tail content as already cached.
active_cell_revision: u64,
config: Config,
model: String,
auth_manager: Arc<AuthManager>,
@@ -340,6 +367,30 @@ pub(crate) struct ChatWidget {
current_rollout_path: Option<PathBuf>,
}
/// Snapshot of active-cell state that affects transcript overlay rendering.
///
/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets
/// it cheaply decide when to recompute that tail as the active cell evolves.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ActiveCellTranscriptKey {
/// Cache-busting revision for in-place updates.
///
/// Many active cells are updated incrementally while streaming (for example when exec groups
/// add output or change status), and the transcript overlay caches its live tail, so this
/// revision gives a cheap way to say "same active cell, but its transcript output is different
/// now". Callers bump it on any mutation that can affect `HistoryCell::transcript_lines`.
pub(crate) revision: u64,
/// Whether the active cell continues the prior stream, which affects
/// spacing between transcript blocks.
pub(crate) is_stream_continuation: bool,
/// Optional animation tick for time-dependent transcript output.
///
/// When this changes, the overlay recomputes the cached tail even if the revision and width
/// are unchanged, which is how shimmer/spinner visuals can animate in the overlay without any
/// underlying data change.
pub(crate) animation_tick: Option<u64>,
}
struct UserMessage {
text: String,
image_paths: Vec<PathBuf>,
@@ -1091,6 +1142,9 @@ impl ChatWidget {
cell.complete_call(&ev.call_id, output, ev.duration);
if cell.should_flush() {
self.flush_active_cell();
} else {
self.bump_active_cell_revision();
self.request_redraw();
}
}
}
@@ -1207,6 +1261,7 @@ impl ChatWidget {
)
{
*cell = new_exec;
self.bump_active_cell_revision();
} else {
self.flush_active_cell();
@@ -1218,6 +1273,7 @@ impl ChatWidget {
interaction_input,
self.config.animations,
)));
self.bump_active_cell_revision();
}
self.request_redraw();
@@ -1231,6 +1287,7 @@ impl ChatWidget {
ev.invocation,
self.config.animations,
)));
self.bump_active_cell_revision();
self.request_redraw();
}
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
@@ -1303,6 +1360,7 @@ impl ChatWidget {
skills: None,
}),
active_cell: None,
active_cell_revision: 0,
config,
model: model.clone(),
auth_manager,
@@ -1387,6 +1445,7 @@ impl ChatWidget {
skills: None,
}),
active_cell: None,
active_cell_revision: 0,
config,
model: model.clone(),
auth_manager,
@@ -2065,6 +2124,12 @@ impl ChatWidget {
self.frame_requester.schedule_frame();
}
fn bump_active_cell_revision(&mut self) {
// Wrapping avoids overflow; wraparound would require 2^64 bumps and at
// worst causes a one-time cache-key collision.
self.active_cell_revision = self.active_cell_revision.wrapping_add(1);
}
fn notify(&mut self, notification: Notification) {
if !notification.allowed_for(&self.config.tui_notifications) {
return;
@@ -3638,6 +3703,37 @@ impl ChatWidget {
self.current_rollout_path.clone()
}
/// Returns a cache key describing the current in-flight active cell for the transcript overlay.
///
/// `Ctrl+T` renders committed transcript cells plus a render-only live tail derived from the
/// current active cell, and the overlay caches that tail; this key is what it uses to decide
/// whether it must recompute. When there is no active cell, this returns `None` so the overlay
/// can drop the tail entirely.
///
/// If callers mutate the active cell's transcript output without bumping the revision (or
/// providing an appropriate animation tick), the overlay will keep showing a stale tail while
/// the main viewport updates.
pub(crate) fn active_cell_transcript_key(&self) -> Option<ActiveCellTranscriptKey> {
let cell = self.active_cell.as_ref()?;
Some(ActiveCellTranscriptKey {
revision: self.active_cell_revision,
is_stream_continuation: cell.is_stream_continuation(),
animation_tick: cell.transcript_animation_tick(),
})
}
/// Returns the active cell's transcript lines for a given terminal width.
///
/// This is a convenience for the transcript overlay live-tail path, and it intentionally
/// filters out empty results so the overlay can treat "nothing to render" as "no tail". Callers
/// should pass the same width the overlay uses; using a different width will cause wrapping
/// mismatches between the main viewport and the transcript overlay.
pub(crate) fn active_cell_transcript_lines(&self, width: u16) -> Option<Vec<Line<'static>>> {
let cell = self.active_cell.as_ref()?;
let lines = cell.transcript_lines(width);
(!lines.is_empty()).then_some(lines)
}
/// Return a reference to the widget's current config (includes any
/// runtime overrides applied via TUI, e.g., model or approval policy).
pub(crate) fn config_ref(&self) -> &Config {
+1
View File
@@ -378,6 +378,7 @@ async fn make_chatwidget_manual(
codex_op_tx: op_tx,
bottom_pane: bottom,
active_cell: None,
active_cell_revision: 0,
config: cfg,
model: resolved_model.clone(),
auth_manager: auth_manager.clone(),
+46 -20
View File
@@ -1,3 +1,15 @@
//! Transcript/history cells for the Codex TUI.
//!
//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed
//! transcript entries and, transiently, an in-flight active cell that can mutate in place while
//! streaming.
//!
//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and
//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on
//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
//! rendered transcript output can change.
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -58,33 +70,26 @@ use unicode_width::UnicodeWidthStr;
/// Visual transcript lines plus soft-wrap joiners.
///
/// A history cell can produce multiple "visual lines" once prefixes/indents and wrapping are
/// applied. Clipboard reconstruction needs more information than just those lines: users expect
/// soft-wrapped prose to copy as a single logical line, while explicit newlines and spacer rows
/// should remain hard breaks.
/// applied. Clipboard reconstruction needs more information than just those lines because users
/// expect soft-wrapped prose to copy as a single logical line, while explicit newlines and spacer
/// rows should remain hard breaks.
///
/// `joiner_before` records, for each output line, whether it is a continuation created by the
/// wrapping algorithm and what string should be inserted at the wrap boundary when joining lines.
/// This avoids heuristics like always inserting a space, and instead preserves the exact whitespace
/// that was skipped at the boundary.
///
/// ## Note for `codex-tui` vs `codex-tui2`
/// In `codex-tui`, `HistoryCell` only exposes `transcript_lines(...)` and the UI generally does not
/// need to reconstruct clipboard text across off-screen history or soft-wrap boundaries. In
/// `codex-tui2`, transcript selection and copy are app-driven (not terminal-driven) and may span
/// content that is not currently visible, so we need extra metadata to distinguish hard breaks from
/// soft wraps and to preserve the exact whitespace at wrap boundaries.
///
/// In `codex-tui`, `HistoryCell` only exposes `transcript_lines(...)` and the UI generally doesn't
/// need to reconstruct clipboard text across off-screen history or soft-wrap boundaries.
///
/// In `codex-tui2`, transcript selection and copy are app-driven (not terminal-driven) and may span
/// content that isn't currently visible. That means we need additional metadata to distinguish hard
/// breaks from soft wraps and to preserve the exact whitespace at wrap boundaries.
///
/// Invariants:
/// - `joiner_before.len() == lines.len()`
/// - `joiner_before[0]` is always `None`
/// - `None` represents a hard break
/// - `Some(joiner)` represents a soft wrap continuation
///
/// Consumers:
/// - `transcript_render` threads joiners through transcript flattening/wrapping.
/// - `transcript_copy` uses them to join wrapped prose while preserving hard breaks.
/// The invariant is that `joiner_before.len() == lines.len()` and `joiner_before[0]` is always
/// `None`. A `None` entry represents a hard break (copy inserts a newline), while `Some(joiner)`
/// represents a soft wrap continuation (copy inserts `joiner` and continues on the same logical
/// line). This data is produced by transcript rendering and consumed by transcript copy to keep
/// clipboard output faithful to what the user saw.
#[derive(Debug, Clone)]
pub(crate) struct TranscriptLinesWithJoiners {
/// Visual transcript lines for a history cell, including any indent/prefix spans.
@@ -162,6 +167,20 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
fn is_stream_continuation(&self) -> bool {
false
}
/// Returns a coarse "animation tick" when transcript output is time-dependent.
///
/// The transcript overlay caches the rendered output of the in-flight active cell, so cells
/// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over
/// time to signal that the cached tail should be recomputed. Returning `None` means the
/// transcript lines are stable, while returning `Some(tick)` during an in-flight animation
/// allows the overlay to keep up with the main viewport.
///
/// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on
/// the first rendered frame even though the main viewport is animating.
fn transcript_animation_tick(&self) -> Option<u64> {
None
}
}
impl Renderable for Box<dyn HistoryCell> {
@@ -1253,6 +1272,13 @@ impl HistoryCell for McpToolCallCell {
lines
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled || self.result.is_some() {
return None;
}
Some((self.start_time.elapsed().as_millis() / 50) as u64)
}
}
pub(crate) fn new_active_mcp_tool_call(
+206 -1
View File
@@ -1,6 +1,24 @@
//! Overlay UIs rendered in an alternate screen.
//!
//! This module implements the pager-style overlays used by the TUI, including the transcript
//! overlay (`Ctrl+T`) that renders a full history view separate from the main viewport.
//!
//! The transcript overlay renders committed transcript cells plus an optional render-only live tail
//! derived from the current in-flight active cell. Because rebuilding wrapped `Line`s on every draw
//! can be expensive, that live tail is cached and only recomputed when its cache key changes, which
//! is derived from the terminal width (wrapping), an active-cell revision (in-place mutations), the
//! stream-continuation flag (spacing), and an animation tick (time-based spinner/shimmer output).
//!
//! The transcript overlay live tail is kept in sync by `App` during draws: `App` supplies an
//! `ActiveCellTranscriptKey` and a function to compute the active cell transcript lines, and
//! `TranscriptOverlay::sync_live_tail` uses the key to decide when the cached tail must be
//! recomputed. `ChatWidget` is responsible for producing a key that changes when the active cell
//! mutates in place or when its transcript output is time-dependent.
use std::io::Result;
use std::sync::Arc;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint;
@@ -420,13 +438,39 @@ impl Renderable for CellRenderable {
}
pub(crate) struct TranscriptOverlay {
/// Pager UI state and the renderables currently displayed.
///
/// The invariant is that `view.renderables` is `render_cells(cells)` plus an optional trailing
/// live-tail renderable appended after the committed cells.
view: PagerView,
/// Committed transcript cells (does not include the live tail).
cells: Vec<Arc<dyn HistoryCell>>,
highlight_cell: Option<usize>,
/// Cache key for the render-only live tail appended after committed cells.
live_tail_key: Option<LiveTailKey>,
is_done: bool,
}
/// Cache key for the active-cell "live tail" appended to the transcript overlay.
///
/// Changing any field implies a different rendered tail.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct LiveTailKey {
/// Current terminal width, which affects wrapping.
width: u16,
/// Revision that changes on in-place active cell transcript updates.
revision: u64,
/// Whether the tail should be treated as a continuation for spacing.
is_stream_continuation: bool,
/// Optional animation tick to refresh spinners/progress indicators.
animation_tick: Option<u64>,
}
impl TranscriptOverlay {
/// Creates a transcript overlay for a fixed set of committed cells.
///
/// This overlay does not own the "active cell"; callers may optionally append a live tail via
/// `sync_live_tail` during draws to reflect in-flight activity.
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
Self {
view: PagerView::new(
@@ -436,6 +480,7 @@ impl TranscriptOverlay {
),
cells: transcript_cells,
highlight_cell: None,
live_tail_key: None,
is_done: false,
}
}
@@ -476,10 +521,85 @@ impl TranscriptOverlay {
.collect()
}
/// Insert a committed history cell while keeping any cached live tail.
///
/// The live tail is temporarily removed, the committed cells are rebuilt,
/// then the tail is reattached. If the tail previously had no leading
/// spacing because it was the only renderable, we add the missing inset
/// when the first committed cell arrives.
///
/// This expects `cell` to be a committed transcript cell (not the in-flight active cell). If
/// the overlay was scrolled to bottom before insertion, it remains pinned to bottom after the
/// insertion to preserve the "follow along" behavior.
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
let follow_bottom = self.view.is_scrolled_to_bottom();
let had_prior_cells = !self.cells.is_empty();
let tail_renderable = self.take_live_tail_renderable();
self.cells.push(cell);
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
if let Some(tail) = tail_renderable {
let tail = if !had_prior_cells
&& self
.live_tail_key
.is_some_and(|key| !key.is_stream_continuation)
{
// The tail was rendered as the only entry, so it lacks a top
// inset; add one now that it follows a committed cell.
Box::new(InsetRenderable::new(tail, Insets::tlbr(1, 0, 0, 0)))
as Box<dyn Renderable>
} else {
tail
};
self.view.renderables.push(tail);
}
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
}
/// Sync the active-cell live tail with the current width and cell state.
///
/// Recomputes the tail only when the cache key changes, preserving scroll
/// position and dropping the tail if there is nothing to render.
///
/// The overlay owns committed transcript cells while the live tail is derived from the current
/// active cell, which can mutate in place while streaming. `App` calls this during
/// `TuiEvent::Draw` for `Overlay::Transcript`, passing a key that changes when the active cell
/// mutates or animates so the cached tail stays fresh.
///
/// Passing a key that does not change on in-place active-cell mutations will freeze the tail in
/// `Ctrl+T` while the main viewport continues to update.
pub(crate) fn sync_live_tail(
&mut self,
width: u16,
active_key: Option<ActiveCellTranscriptKey>,
compute_lines: impl FnOnce(u16) -> Option<Vec<Line<'static>>>,
) {
let next_key = active_key.map(|key| LiveTailKey {
width,
revision: key.revision,
is_stream_continuation: key.is_stream_continuation,
animation_tick: key.animation_tick,
});
if self.live_tail_key == next_key {
return;
}
let follow_bottom = self.view.is_scrolled_to_bottom();
self.take_live_tail_renderable();
self.live_tail_key = next_key;
if let Some(key) = next_key {
let lines = compute_lines(width).unwrap_or_default();
if !lines.is_empty() {
self.view.renderables.push(Self::live_tail_renderable(
lines,
!self.cells.is_empty(),
key.is_stream_continuation,
));
}
}
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
@@ -487,12 +607,50 @@ impl TranscriptOverlay {
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell;
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
self.rebuild_renderables();
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
}
}
/// Returns whether the underlying pager view is currently pinned to the bottom.
///
/// This is used by the `App` draw loop to decide whether to schedule animation frames for the
/// live tail (if the user has scrolled up, we avoid driving animation).
pub(crate) fn is_scrolled_to_bottom(&self) -> bool {
self.view.is_scrolled_to_bottom()
}
fn rebuild_renderables(&mut self) {
let tail_renderable = self.take_live_tail_renderable();
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
if let Some(tail) = tail_renderable {
self.view.renderables.push(tail);
}
}
/// Removes and returns the cached live-tail renderable, if present.
///
/// The live tail is represented as a single optional renderable appended after the committed
/// cell renderables, so this relies on the live tail always being the final entry in
/// `view.renderables` when present.
fn take_live_tail_renderable(&mut self) -> Option<Box<dyn Renderable>> {
(self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())?
}
fn live_tail_renderable(
lines: Vec<Line<'static>>,
has_prior_cells: bool,
is_stream_continuation: bool,
) -> Box<dyn Renderable> {
let paragraph = Paragraph::new(Text::from(lines));
let mut renderable: Box<dyn Renderable> = Box::new(CachedRenderable::new(paragraph));
if has_prior_cells && !is_stream_continuation {
renderable = Box::new(InsetRenderable::new(renderable, Insets::tlbr(1, 0, 0, 0)));
}
renderable
}
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
@@ -633,6 +791,7 @@ mod tests {
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ReviewDecision;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@@ -717,6 +876,52 @@ mod tests {
assert_snapshot!(term.backend());
}
#[test]
fn transcript_overlay_renders_live_tail() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
overlay.sync_live_tail(
40,
Some(ActiveCellTranscriptKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![Line::from("tail")]),
);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
.expect("draw");
assert_snapshot!(term.backend());
}
#[test]
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
let calls = std::cell::Cell::new(0usize);
let key = ActiveCellTranscriptKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
};
overlay.sync_live_tail(40, Some(key), |_| {
calls.set(calls.get() + 1);
Some(vec![Line::from("tail")])
});
overlay.sync_live_tail(40, Some(key), |_| {
calls.set(calls.get() + 1);
Some(vec![Line::from("tail2")])
});
assert_eq!(calls.get(), 1);
}
fn buffer_to_text(buf: &Buffer, area: Rect) -> String {
let mut out = String::new();
for y in area.y..area.bottom() {
@@ -0,0 +1,14 @@
---
source: tui2/src/pager_overlay.rs
expression: term.backend()
---
"/ T R A N S C R I P T / / / / / / / / / "
"alpha "
" "
"tail "
"~ "
"~ "
"───────────────────────────────── 100% ─"
" ↑/↓ to scroll pgup/pgdn to page hom"
" q to quit esc to edit prev "
" "