mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
490c1c1fdd
commit
12779c7c07
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+14
@@ -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 "
|
||||
" "
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+14
@@ -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 "
|
||||
" "
|
||||
Reference in New Issue
Block a user