diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index c28680dd9..cd38131d1 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -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() { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 08b6f3a8b..34c0bba78 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, bottom_pane: BottomPane, active_cell: Option>, + /// 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, @@ -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, +} + struct UserMessage { text: String, image_paths: Vec, @@ -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 { + 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>> { + 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 { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 94aff345a..3f1c36ddd 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4e16306d1..3f14d84fa 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -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 { + None + } } impl Renderable for Box { @@ -448,6 +474,7 @@ pub(crate) fn new_unified_exec_interaction( pub(crate) struct UnifiedExecWaitCell { command_display: Option, 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) { - 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) -> 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 { @@ -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 { + 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 { + 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( diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 46aaba864..ae71a58c6 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -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>, highlight_cell: Option, + /// Cache key for the render-only live tail appended after committed cells. + live_tail_key: Option, 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, +} + 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>) -> 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) { 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 + } 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, + compute_lines: impl FnOnce(u16) -> Option>>, + ) { + 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) { 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> { + (self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())? + } + + fn live_tail_renderable( + lines: Vec>, + has_prior_cells: bool, + is_stream_continuation: bool, + ) -> Box { + let paragraph = Paragraph::new(Text::from(lines)); + let mut renderable: Box = 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() { diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap new file mode 100644 index 000000000..05ea90246 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_renders_live_tail.snap @@ -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 " +" " diff --git a/codex-rs/tui2/src/app_backtrack.rs b/codex-rs/tui2/src/app_backtrack.rs index c5c2f0e95..d2aca3ced 100644 --- a/codex-rs/tui2/src/app_backtrack.rs +++ b/codex-rs/tui2/src/app_backtrack.rs @@ -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() { diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index c0ea54217..dd0bacf23 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -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, bottom_pane: BottomPane, active_cell: Option>, + /// 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, @@ -340,6 +367,30 @@ pub(crate) struct ChatWidget { current_rollout_path: Option, } +/// 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, +} + struct UserMessage { text: String, image_paths: Vec, @@ -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 { + 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>> { + 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 { diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 2d847c84a..f5134d13b 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -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(), diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index 3124d0fc3..46e7bed34 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -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 { + None + } } impl Renderable for Box { @@ -1253,6 +1272,13 @@ impl HistoryCell for McpToolCallCell { lines } + + fn transcript_animation_tick(&self) -> Option { + 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( diff --git a/codex-rs/tui2/src/pager_overlay.rs b/codex-rs/tui2/src/pager_overlay.rs index 3d24aef96..6fb6d123c 100644 --- a/codex-rs/tui2/src/pager_overlay.rs +++ b/codex-rs/tui2/src/pager_overlay.rs @@ -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>, highlight_cell: Option, + /// Cache key for the render-only live tail appended after committed cells. + live_tail_key: Option, 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, +} + 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>) -> 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) { 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 + } 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, + compute_lines: impl FnOnce(u16) -> Option>>, + ) { + 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) { 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> { + (self.view.renderables.len() > self.cells.len()).then(|| self.view.renderables.pop())? + } + + fn live_tail_renderable( + lines: Vec>, + has_prior_cells: bool, + is_stream_continuation: bool, + ) -> Box { + let paragraph = Paragraph::new(Text::from(lines)); + let mut renderable: Box = 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() { diff --git a/codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_renders_live_tail.snap b/codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_renders_live_tail.snap new file mode 100644 index 000000000..5911dd96b --- /dev/null +++ b/codex-rs/tui2/src/snapshots/codex_tui2__pager_overlay__tests__transcript_overlay_renders_live_tail.snap @@ -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 " +" "