From 8325697762e5656c5a964bf6e322ca8fd9f8e667 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Wed, 26 Nov 2025 19:01:44 -0800 Subject: [PATCH 01/20] refactor: tui.rs extract several pieces Pull FrameRequester out of tui.rs into its own module and make a FrameScheduler struct. This is effectively an Actor/Handler approach (see https://ryhl.io/blog/actors-with-tokio/). Adds tests and docs. Small refactor of pending_viewport_area logic. --- codex-rs/Cargo.lock | 2 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/tui.rs | 131 +++---------- codex-rs/tui/src/tui/frame_requester.rs | 249 ++++++++++++++++++++++++ 4 files changed, 283 insertions(+), 100 deletions(-) create mode 100644 codex-rs/tui/src/tui/frame_requester.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4c87b8dc055..87740b9b125 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1612,6 +1612,7 @@ dependencies = [ "textwrap 0.16.2", "tokio", "tokio-stream", + "tokio-util", "toml", "tracing", "tracing-appender", @@ -6577,6 +6578,7 @@ dependencies = [ "futures-sink", "futures-util", "pin-project-lite", + "slab", "tokio", ] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index d9906b2f01c..be4f5aead70 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -91,6 +91,7 @@ unicode-width = { workspace = true } url = { workspace = true } codex-windows-sandbox = { workspace = true } +tokio-util = { workspace = true, features = ["time"] } [target.'cfg(unix)'.dependencies] libc = { workspace = true } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 7cbf252e7fc..5502b833569 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -9,8 +9,6 @@ use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::time::Duration; -use std::time::Instant; use crossterm::Command; use crossterm::SynchronizedUpdate; @@ -32,10 +30,13 @@ use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; use ratatui::layout::Offset; +use ratatui::layout::Rect; use ratatui::text::Line; use tokio::select; +use tokio::sync::broadcast; use tokio_stream::Stream; +pub use self::frame_requester::FrameRequester; use crate::custom_terminal; use crate::custom_terminal::Terminal as CustomTerminal; #[cfg(unix)] @@ -43,6 +44,7 @@ use crate::tui::job_control::SUSPEND_KEY; #[cfg(unix)] use crate::tui::job_control::SuspendContext; +mod frame_requester; #[cfg(unix)] mod job_control; @@ -159,8 +161,8 @@ pub enum TuiEvent { } pub struct Tui { - frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, - draw_tx: tokio::sync::broadcast::Sender<()>, + frame_requester: FrameRequester, + draw_tx: broadcast::Sender<()>, pub(crate) terminal: Terminal, pending_history_lines: Vec>, alt_saved_viewport: Option, @@ -173,36 +175,10 @@ pub struct Tui { enhanced_keys_supported: bool, } -#[derive(Clone, Debug)] -pub struct FrameRequester { - frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, -} -impl FrameRequester { - pub fn schedule_frame(&self) { - let _ = self.frame_schedule_tx.send(Instant::now()); - } - pub fn schedule_frame_in(&self, dur: Duration) { - let _ = self.frame_schedule_tx.send(Instant::now() + dur); - } -} - -#[cfg(test)] -impl FrameRequester { - /// Create a no-op frame requester for tests. - pub(crate) fn test_dummy() -> Self { - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - FrameRequester { - frame_schedule_tx: tx, - } - } -} - impl Tui { pub fn new(terminal: Terminal) -> Self { - let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel(); - let (draw_tx, _) = tokio::sync::broadcast::channel(1); - - spawn_frame_scheduler(frame_schedule_rx, draw_tx.clone()); + let (draw_tx, _) = broadcast::channel(1); + let frame_requester = FrameRequester::new(draw_tx.clone()); // Detect keyboard enhancement support before any EventStream is created so the // crossterm poller can acquire its lock without contention. @@ -212,7 +188,7 @@ impl Tui { let _ = crate::terminal_palette::default_colors(); Self { - frame_schedule_tx, + frame_requester, draw_tx, terminal, pending_history_lines: vec![], @@ -226,9 +202,7 @@ impl Tui { } pub fn frame_requester(&self) -> FrameRequester { - FrameRequester { - frame_schedule_tx: self.frame_schedule_tx.clone(), - } + self.frame_requester.clone() } pub fn enhanced_keys_supported(&self) -> bool { @@ -362,34 +336,14 @@ impl Tui { // Precompute any viewport updates that need a cursor-position query before entering // the synchronized update, to avoid racing with the event reader. - let mut pending_viewport_area: Option = None; - { - let terminal = &mut self.terminal; - let screen_size = terminal.size()?; - let last_known_screen_size = terminal.last_known_screen_size; - if screen_size != last_known_screen_size - && let Ok(cursor_pos) = terminal.get_cursor_position() - { - let last_known_cursor_pos = terminal.last_known_cursor_pos; - // If we resized AND the cursor moved, we adjust the viewport area to keep the - // cursor in the same position. This is a heuristic that seems to work well - // at least in iTerm2. - if cursor_pos.y != last_known_cursor_pos.y { - let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; - let new_viewport_area = terminal.viewport_area.offset(Offset { - x: 0, - y: cursor_delta, - }); - pending_viewport_area = Some(new_viewport_area); - } - } - } + let mut pending_viewport_area = self.pending_viewport_area()?; stdout().sync_update(|_| { #[cfg(unix)] if let Some(prepared) = prepared_resume.take() { prepared.apply(&mut self.terminal)?; } + let terminal = &mut self.terminal; if let Some(new_area) = pending_viewport_area.take() { terminal.set_viewport_area(new_area); @@ -440,51 +394,28 @@ impl Tui { }) })? } -} -/// Spawn background scheduler to coalesce frame requests and emit draws at deadlines. -fn spawn_frame_scheduler( - frame_schedule_rx: tokio::sync::mpsc::UnboundedReceiver, - draw_tx: tokio::sync::broadcast::Sender<()>, -) { - tokio::spawn(async move { - use tokio::select; - use tokio::time::Instant as TokioInstant; - use tokio::time::sleep_until; - - let mut rx = frame_schedule_rx; - let mut next_deadline: Option = None; - - loop { - let target = next_deadline - .unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365)); - let sleep_fut = sleep_until(TokioInstant::from_std(target)); - tokio::pin!(sleep_fut); - - select! { - recv = rx.recv() => { - match recv { - Some(at) => { - if next_deadline.is_none_or(|cur| at < cur) { - next_deadline = Some(at); - } - // Do not send a draw immediately here. By continuing the loop, - // we recompute the sleep target so the draw fires once via the - // sleep branch, coalescing multiple requests into a single draw. - continue; - } - None => break, - } - } - _ = &mut sleep_fut => { - if next_deadline.is_some() { - next_deadline = None; - let _ = draw_tx.send(()); - } - } + fn pending_viewport_area(&mut self) -> Result> { + let terminal = &mut self.terminal; + let screen_size = terminal.size()?; + let last_known_screen_size = terminal.last_known_screen_size; + if screen_size != last_known_screen_size + && let Ok(cursor_pos) = terminal.get_cursor_position() + { + let last_known_cursor_pos = terminal.last_known_cursor_pos; + // If we resized AND the cursor moved, we adjust the viewport area to keep the + // cursor in the same position. This is a heuristic that seems to work well + // at least in iTerm2. + if cursor_pos.y != last_known_cursor_pos.y { + let offset = Offset { + x: 0, + y: cursor_pos.y as i32 - last_known_cursor_pos.y as i32, + }; + return Ok(Some(terminal.viewport_area.offset(offset))); } } - }); + Ok(None) + } } /// Command that emits an OSC 9 desktop notification with a message. diff --git a/codex-rs/tui/src/tui/frame_requester.rs b/codex-rs/tui/src/tui/frame_requester.rs new file mode 100644 index 00000000000..4f7886aa22c --- /dev/null +++ b/codex-rs/tui/src/tui/frame_requester.rs @@ -0,0 +1,249 @@ +//! Frame draw scheduling utilities for the TUI. +//! +//! This module exposes [`FrameRequester`], a lightweight handle that widgets and +//! background tasks can clone to request future redraws of the TUI. +//! +//! Internally it spawns a [`FrameScheduler`] task that coalesces many requests +//! into a single notification on a broadcast channel used by the main TUI event +//! loop. This keeps animations and status updates smooth without redrawing more +//! often than necessary. +//! +//! This follows the actor-style design from +//! [“Actors with Tokio”](https://ryhl.io/blog/actors-with-tokio/), with a +//! dedicated scheduler task and lightweight request handles. + +use std::time::Duration; +use std::time::Instant; + +use tokio::sync::broadcast; +use tokio::sync::mpsc; + +/// A requester for scheduling future frame draws on the TUI event loop. +/// +/// This is the handler side of an actor/handler pair with `FrameScheduler`, which coalesces +/// multiple frame requests into a single draw operation. +/// +/// Clones of this type can be freely shared across tasks to make it possible to trigger frame draws +/// from anywhere in the TUI code. +#[derive(Clone, Debug)] +pub struct FrameRequester { + frame_schedule_tx: mpsc::UnboundedSender, +} + +impl FrameRequester { + /// Create a new FrameRequester and spawn its associated FrameScheduler task. + /// + /// The provided `draw_tx` is used to notify the TUI event loop of scheduled draws. + pub fn new(draw_tx: broadcast::Sender<()>) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let scheduler = FrameScheduler::new(rx, draw_tx); + tokio::spawn(scheduler.run()); + Self { + frame_schedule_tx: tx, + } + } + + /// Schedule a frame draw as soon as possible. + pub fn schedule_frame(&self) { + let _ = self.frame_schedule_tx.send(Instant::now()); + } + + /// Schedule a frame draw to occur after the specified duration. + pub fn schedule_frame_in(&self, dur: Duration) { + let _ = self.frame_schedule_tx.send(Instant::now() + dur); + } +} + +#[cfg(test)] +impl FrameRequester { + /// Create a no-op frame requester for tests. + pub(crate) fn test_dummy() -> Self { + let (tx, _rx) = mpsc::unbounded_channel(); + FrameRequester { + frame_schedule_tx: tx, + } + } +} + +/// A scheduler for coalescing frame draw requests and notifying the TUI event loop. +/// +/// This type is internal to `FrameRequester` and is spawned as a task to handle scheduling logic. +struct FrameScheduler { + receiver: mpsc::UnboundedReceiver, + draw_tx: broadcast::Sender<()>, +} + +impl FrameScheduler { + /// Create a new FrameScheduler with the provided receiver and draw notification sender. + fn new(receiver: mpsc::UnboundedReceiver, draw_tx: broadcast::Sender<()>) -> Self { + Self { receiver, draw_tx } + } + + /// Run the scheduling loop, coalescing frame requests and notifying the TUI event loop. + /// + /// This method runs indefinitely until all senders are dropped. A single draw notification + /// is sent for multiple requests scheduled before the next draw deadline. + async fn run(mut self) { + const ONE_YEAR: Duration = Duration::from_secs(60 * 60 * 24 * 365); + let mut next_deadline: Option = None; + loop { + let target = next_deadline.unwrap_or_else(|| Instant::now() + ONE_YEAR); + let deadline = tokio::time::sleep_until(target.into()); + tokio::pin!(deadline); + + tokio::select! { + draw_at = self.receiver.recv() => { + let Some(draw_at) = draw_at else { + // All senders dropped; exit the scheduler. + break + }; + next_deadline = Some(next_deadline.map_or(draw_at, |cur| cur.min(draw_at))); + + // Do not send a draw immediately here. By continuing the loop, + // we recompute the sleep target so the draw fires once via the + // sleep branch, coalescing multiple requests into a single draw. + continue; + } + _ = &mut deadline => { + if next_deadline.is_some() { + next_deadline = None; + let _ = self.draw_tx.send(()); + } + } + } + } + } +} +#[cfg(test)] +mod tests { + use super::*; + use tokio::time; + use tokio_util::time::FutureExt; + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_schedule_frame_immediate_triggers_once() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + requester.schedule_frame(); + + // Advance time minimally to let the scheduler process and hit the deadline == now. + time::advance(Duration::from_millis(1)).await; + + // First draw should arrive. + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for first draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // No second draw should arrive. + let second = draw_rx.recv().timeout(Duration::from_millis(20)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_schedule_frame_in_triggers_at_delay() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + requester.schedule_frame_in(Duration::from_millis(50)); + + // Advance less than the delay: no draw yet. + time::advance(Duration::from_millis(30)).await; + let early = draw_rx.recv().timeout(Duration::from_millis(10)).await; + assert!(early.is_err(), "draw fired too early"); + + // Advance past the deadline: one draw should fire. + time::advance(Duration::from_millis(25)).await; + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for scheduled draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // No second draw should arrive. + let second = draw_rx.recv().timeout(Duration::from_millis(20)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_coalesces_multiple_requests_into_single_draw() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + // Schedule multiple immediate requests close together. + requester.schedule_frame(); + requester.schedule_frame(); + requester.schedule_frame(); + + // Allow the scheduler to process and hit the coalesced deadline. + time::advance(Duration::from_millis(1)).await; + + // Expect only a single draw notification despite three requests. + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for coalesced draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // No additional draw should be sent for the same coalesced batch. + let second = draw_rx.recv().timeout(Duration::from_millis(20)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_coalesces_mixed_immediate_and_delayed_requests() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + // Schedule a delayed draw and then an immediate one; should coalesce and fire at the earliest (immediate). + requester.schedule_frame_in(Duration::from_millis(100)); + requester.schedule_frame(); + + time::advance(Duration::from_millis(1)).await; + + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for coalesced immediate draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // The later delayed request should have been coalesced into the earlier one; no second draw. + let second = draw_rx.recv().timeout(Duration::from_millis(120)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } + + #[tokio::test(flavor = "current_thread", start_paused = true)] + async fn test_multiple_delayed_requests_coalesce_to_earliest() { + let (draw_tx, mut draw_rx) = broadcast::channel(16); + let requester = FrameRequester::new(draw_tx); + + // Schedule multiple delayed draws; they should coalesce to the earliest (10ms). + requester.schedule_frame_in(Duration::from_millis(100)); + requester.schedule_frame_in(Duration::from_millis(20)); + requester.schedule_frame_in(Duration::from_millis(120)); + + // Advance to just before the earliest deadline: no draw yet. + time::advance(Duration::from_millis(10)).await; + let early = draw_rx.recv().timeout(Duration::from_millis(10)).await; + assert!(early.is_err(), "draw fired too early"); + + // Advance past the earliest deadline: one draw should fire. + time::advance(Duration::from_millis(20)).await; + let first = draw_rx + .recv() + .timeout(Duration::from_millis(50)) + .await + .expect("timed out waiting for earliest coalesced draw"); + assert!(first.is_ok(), "broadcast closed unexpectedly"); + + // No additional draw should fire for the later delayed requests. + let second = draw_rx.recv().timeout(Duration::from_millis(120)).await; + assert!(second.is_err(), "unexpected extra draw received"); + } +} From 1590c44599c32e9b94ab1cf0ca548064a436384f Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 1 Dec 2025 14:44:41 -0800 Subject: [PATCH 02/20] feat: render transcript above composer - Add App::render_transcript_cells to draw history cells in a transcript area above the chat composer. - Use the full terminal height in the main draw loop and pin the composer to the bottom of the screen. - Start storing history cells in App::transcript_cells when InsertHistoryCell arrives, rather than only queuing vt100 history lines. --- codex-rs/tui/src/app.rs | 75 +++++++++++++++++++++++++++++++++++++---- codex-rs/tui/src/tui.rs | 27 +++++++-------- 2 files changed, 82 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d0e057102c9..1ebc7c9da11 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,6 +3,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; +use crate::custom_terminal::Frame; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; @@ -42,9 +43,11 @@ use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::path::PathBuf; use std::sync::Arc; @@ -425,12 +428,22 @@ impl App { return Ok(true); } tui.draw( - self.chat_widget.desired_height(tui.terminal.size()?.width), + //self.chat_widget.desired_height(tui.terminal.size()?.width), + tui.terminal.size()?.height, |frame| { - self.chat_widget.render(frame.area(), frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + let chat_height = self.chat_widget.desired_height(frame.area().width); + // peg chat to the bottom + let chat_area = Rect { + x: frame.area().x, + y: frame.area().bottom().saturating_sub(chat_height), + width: frame.area().width, + height: chat_height, + }; + self.chat_widget.render(chat_area, frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { frame.set_cursor_position((x, y)); } + self.render_transcript_cells(frame, &self.transcript_cells); }, )?; } @@ -439,6 +452,56 @@ impl App { Ok(true) } + pub(crate) fn render_transcript_cells( + &self, + frame: &mut Frame, + cells: &[Arc], + ) { + if cells.is_empty() { + return; + } + + let area = frame.area(); + if area.width == 0 || area.height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(area.width); + if chat_height >= area.height { + return; + } + + let transcript_height = area.height.saturating_sub(chat_height); + if transcript_height == 0 { + return; + } + + let transcript_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: transcript_height, + }; + + let mut lines: Vec> = Vec::new(); + for cell in cells { + let cell_lines = cell.display_lines(transcript_area.width); + if cell_lines.is_empty() { + continue; + } + lines.extend(cell_lines); + } + + if lines.is_empty() { + return; + } + + let total = lines.len(); + let start = total.saturating_sub(transcript_area.height as usize); + let visible: Vec> = lines.into_iter().skip(start).collect(); + Paragraph::new(visible).render_ref(transcript_area, frame.buffer); + } + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { @@ -470,8 +533,8 @@ impl App { } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); - if let Some(Overlay::Transcript(t)) = &mut self.overlay { - t.insert_cell(cell.clone()); + if let Some(Overlay::Transcript(transcript)) = &mut self.overlay { + transcript.insert_cell(cell.clone()); tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); @@ -490,7 +553,7 @@ impl App { if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { - tui.insert_history_lines(display); + //tui.insert_history_lines(display); } } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 5502b833569..e3a452e2513 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -24,7 +24,6 @@ use crossterm::event::PushKeyboardEnhancementFlags; use crossterm::terminal::EnterAlternateScreen; use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; -use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; @@ -356,25 +355,25 @@ impl Tui { area.height = height.min(size.height); area.width = size.width; // If the viewport has expanded, scroll everything else up to make room. - if area.bottom() > size.height { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), area.bottom() - size.height)?; - area.y = size.height - area.height; - } + // if area.bottom() > size.height { + // terminal + // .backend_mut() + // .scroll_region_up(0..area.top(), area.bottom() - size.height)?; + // area.y = size.height - area.height; + // } if area != terminal.viewport_area { // TODO(nornagon): probably this could be collapsed with the clear + set_viewport_area above. terminal.clear()?; terminal.set_viewport_area(area); } - if !self.pending_history_lines.is_empty() { - crate::insert_history::insert_history_lines( - terminal, - self.pending_history_lines.clone(), - )?; - self.pending_history_lines.clear(); - } + // if !self.pending_history_lines.is_empty() { + // crate::insert_history::insert_history_lines( + // terminal, + // self.pending_history_lines.clone(), + // )?; + // self.pending_history_lines.clear(); + // } // Update the y position for suspending so Ctrl-Z can place the cursor correctly. #[cfg(unix)] From 01a1819755753c027806942134f9df0da9ccd02f Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 1 Dec 2025 15:32:31 -0800 Subject: [PATCH 03/20] feat: wrap transcript and enable mouse scroll - Wrap transcript lines using word_wrap_lines_borrowed so history uses viewport-aware soft wrapping. - Space non-streaming history cells with a single blank line while avoiding gaps between streaming chunks. - Enable application mouse mode and disable alternate scroll so wheel events arrive as Mouse events instead of arrow keys. --- codex-rs/tui/src/app.rs | 53 +++++++++++++++++++++++++++++++++-------- codex-rs/tui/src/tui.rs | 11 +++++++-- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1ebc7c9da11..2f0488a1263 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -18,6 +18,7 @@ use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; +use crate::wrapping::word_wrap_lines_borrowed; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::AuthMode; use codex_common::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; @@ -46,6 +47,7 @@ use crossterm::event::KeyEventKind; use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; @@ -457,10 +459,6 @@ impl App { frame: &mut Frame, cells: &[Arc], ) { - if cells.is_empty() { - return; - } - let area = frame.area(); if area.width == 0 || area.height == 0 { return; @@ -476,6 +474,10 @@ impl App { return; } + if cells.is_empty() { + return; + } + let transcript_area = Rect { x: area.x, y: area.y, @@ -484,22 +486,53 @@ impl App { }; let mut lines: Vec> = Vec::new(); + let mut has_emitted_lines = false; + for cell in cells { - let cell_lines = cell.display_lines(transcript_area.width); + let mut cell_lines = cell.display_lines(transcript_area.width); if cell_lines.is_empty() { continue; } - lines.extend(cell_lines); + + if !cell.is_stream_continuation() { + if has_emitted_lines { + lines.push(Line::from("")); + } else { + has_emitted_lines = true; + } + } + + lines.append(&mut cell_lines); } if lines.is_empty() { return; } - let total = lines.len(); - let start = total.saturating_sub(transcript_area.height as usize); - let visible: Vec> = lines.into_iter().skip(start).collect(); - Paragraph::new(visible).render_ref(transcript_area, frame.buffer); + let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize); + if wrapped.is_empty() { + return; + } + + let total_lines = wrapped.len(); + let max_visible = transcript_area.height as usize; + let start_index = total_lines.saturating_sub(max_visible); + + Clear.render_ref(transcript_area, frame.buffer); + + for (row_index, line_index) in (start_index..total_lines).enumerate() { + if row_index >= max_visible { + break; + } + let y = transcript_area.y + row_index as u16; + let row_area = Rect { + x: transcript_area.x, + y, + width: transcript_area.width, + height: 1, + }; + wrapped[line_index].render_ref(row_area, frame.buffer); + } } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index e3a452e2513..627062e3f83 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -14,8 +14,10 @@ use crossterm::Command; use crossterm::SynchronizedUpdate; use crossterm::event::DisableBracketedPaste; use crossterm::event::DisableFocusChange; +use crossterm::event::DisableMouseCapture; use crossterm::event::EnableBracketedPaste; use crossterm::event::EnableFocusChange; +use crossterm::event::EnableMouseCapture; use crossterm::event::Event; use crossterm::event::KeyEvent; use crossterm::event::KeyboardEnhancementFlags; @@ -70,6 +72,12 @@ pub fn set_modes() -> Result<()> { ); let _ = execute!(stdout(), EnableFocusChange); + // Ensure any pre-existing alternate scroll mode is disabled so mouse + // wheel events don't get translated into Up/Down keys by the terminal, + // and enable application mouse mode so scroll events are delivered as + // Mouse events instead of arrow keys. + let _ = execute!(stdout(), DisableAlternateScroll); + let _ = execute!(stdout(), EnableMouseCapture); Ok(()) } @@ -120,6 +128,7 @@ impl Command for DisableAlternateScroll { pub fn restore() -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + let _ = execute!(stdout(), DisableMouseCapture); execute!(stdout(), DisableBracketedPaste)?; let _ = execute!(stdout(), DisableFocusChange); disable_raw_mode()?; @@ -288,8 +297,6 @@ impl Tui { /// inline viewport for restoration when leaving. pub fn enter_alt_screen(&mut self) -> Result<()> { let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen); - // Enable "alternate scroll" so terminals may translate wheel to arrows - let _ = execute!(self.terminal.backend_mut(), EnableAlternateScroll); if let Ok(size) = self.terminal.size() { self.alt_saved_viewport = Some(self.terminal.viewport_area); self.terminal.set_viewport_area(ratatui::layout::Rect::new( From 13ed4470a46efa436ae58d3d6be35232b85e6db8 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 1 Dec 2025 17:03:10 -0800 Subject: [PATCH 04/20] feat: add transcript scroll plumbing - Introduce a TranscriptScroll enum and field on App to track whether the transcript is pinned to the bottom or anchored at a specific cell/line. - Extend TuiEvent with a Mouse variant and plumb mouse events through the TUI, stubbing them out where not yet handled. - Enter the alternate screen around App::run so the main chat + transcript view uses the full terminal while preserving normal scrollback on exit. --- codex-rs/tui/src/app.rs | 36 +++++++++++++------ codex-rs/tui/src/lib.rs | 5 +++ codex-rs/tui/src/model_migration.rs | 1 + .../tui/src/onboarding/onboarding_screen.rs | 1 + codex-rs/tui/src/tui.rs | 4 +++ 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2f0488a1263..2c5a280b27d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -18,7 +18,6 @@ use crate::resume_picker::ResumeSelection; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; -use crate::wrapping::word_wrap_lines_borrowed; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::AuthMode; use codex_common::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; @@ -210,6 +209,7 @@ pub(crate) struct App { pub(crate) file_search: FileSearchManager, pub(crate) transcript_cells: Vec>, + transcript_scroll: TranscriptScroll, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -235,6 +235,21 @@ pub(crate) struct App { skip_world_writable_scan_once: bool, } +#[derive(Debug, Clone, Copy)] +enum TranscriptScroll { + ToBottom, + Scrolled { + cell_index: usize, + line_in_cell: usize, + }, +} + +impl Default for TranscriptScroll { + fn default() -> Self { + TranscriptScroll::ToBottom + } +} + impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { @@ -333,6 +348,7 @@ impl App { file_search, enhanced_keys_supported, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -413,6 +429,9 @@ impl App { TuiEvent::Key(key_event) => { self.handle_key_event(tui, key_event).await; } + TuiEvent::Mouse(_) => { + // Mouse scroll for transcript not yet implemented. + } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), // but tui-textarea expects \n. Normalize CR to LF. @@ -454,7 +473,7 @@ impl App { Ok(true) } - pub(crate) fn render_transcript_cells( +pub(crate) fn render_transcript_cells( &self, frame: &mut Frame, cells: &[Arc], @@ -509,17 +528,12 @@ impl App { return; } - let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize); - if wrapped.is_empty() { - return; - } + Clear.render_ref(transcript_area, frame.buffer); - let total_lines = wrapped.len(); + let total_lines = lines.len(); let max_visible = transcript_area.height as usize; let start_index = total_lines.saturating_sub(max_visible); - Clear.render_ref(transcript_area, frame.buffer); - for (row_index, line_index) in (start_index..total_lines).enumerate() { if row_index >= max_visible { break; @@ -531,7 +545,7 @@ impl App { width: transcript_area.width, height: 1, }; - wrapped[line_index].render_ref(row_area, frame.buffer); + lines[line_index].render_ref(row_area, frame.buffer); } } @@ -1144,6 +1158,7 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -1181,6 +1196,7 @@ mod tests { active_profile: None, file_search, transcript_cells: Vec::new(), + transcript_scroll: TranscriptScroll::ToBottom, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 33bd18c4379..2854176982a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -495,6 +495,10 @@ async fn run_ratatui_app( let Cli { prompt, images, .. } = cli; + // Use alternate screen for the main interactive session so the viewport + // covers the full terminal and we don't leave gaps in scrollback. + let _ = tui.enter_alt_screen(); + let app_result = App::run( &mut tui, auth_manager, @@ -507,6 +511,7 @@ async fn run_ratatui_app( ) .await; + let _ = tui.leave_alt_screen(); restore(); // Mark the end of the recorded session. session_log::log_session_end(); diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 283007e0289..0e3ec5aab63 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -108,6 +108,7 @@ pub(crate) async fn run_model_migration_prompt( frame.render_widget_ref(&screen, frame.area()); }); } + TuiEvent::Mouse(_) => {} } } else { screen.accept(); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 47c7811a3b8..51101c74743 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -420,6 +420,7 @@ pub(crate) async fn run_onboarding_app( frame.render_widget_ref(&onboarding_screen, frame.area()); }); } + TuiEvent::Mouse(_) => {} } } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 627062e3f83..5edcc764611 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -166,6 +166,7 @@ pub enum TuiEvent { Key(KeyEvent), Paste(String), Draw, + Mouse(crossterm::event::MouseEvent), } pub struct Tui { @@ -262,6 +263,9 @@ impl Tui { Event::Paste(pasted) => { yield TuiEvent::Paste(pasted); } + Event::Mouse(mouse_event) => { + yield TuiEvent::Mouse(mouse_event); + } Event::FocusGained => { terminal_focused.store(true, Ordering::Relaxed); crate::terminal_palette::requery_default_colors(); From 99c761fa1ea90db896154a5dd5ab20b0d2712cb4 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 1 Dec 2025 17:28:21 -0800 Subject: [PATCH 05/20] feat: implement transcript scrolling statefully - Replace ad-hoc transcript line collection with build_transcript_lines, tracking cell and line indices for each flattened line. - Use TranscriptScroll plus transcript_view_top to anchor scrolling to a specific history cell while new content arrives or the viewport size changes. - Implement scroll_transcript and hook it up to mouse wheel events so the transcript can be scrolled independently of the composer. --- codex-rs/tui/src/app.rs | 221 +++++++++++++++++++++++++++++++++++----- 1 file changed, 193 insertions(+), 28 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2c5a280b27d..e33e5f2926a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -429,8 +429,8 @@ impl App { TuiEvent::Key(key_event) => { self.handle_key_event(tui, key_event).await; } - TuiEvent::Mouse(_) => { - // Mouse scroll for transcript not yet implemented. + TuiEvent::Mouse(mouse_event) => { + self.handle_mouse_event(tui, mouse_event); } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), @@ -448,6 +448,7 @@ impl App { { return Ok(true); } + let cells = self.transcript_cells.clone(); tui.draw( //self.chat_widget.desired_height(tui.terminal.size()?.width), tui.terminal.size()?.height, @@ -464,7 +465,7 @@ impl App { if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { frame.set_cursor_position((x, y)); } - self.render_transcript_cells(frame, &self.transcript_cells); + self.render_transcript_cells(frame, &cells); }, )?; } @@ -473,8 +474,8 @@ impl App { Ok(true) } -pub(crate) fn render_transcript_cells( - &self, + pub(crate) fn render_transcript_cells( + &mut self, frame: &mut Frame, cells: &[Arc], ) { @@ -494,6 +495,16 @@ pub(crate) fn render_transcript_cells( } if cells.is_empty() { + Clear.render_ref( + Rect { + x: area.x, + y: area.y, + width: area.width, + height: transcript_height, + }, + frame.buffer, + ); + self.transcript_scroll = TranscriptScroll::ToBottom; return; } @@ -504,37 +515,45 @@ pub(crate) fn render_transcript_cells( height: transcript_height, }; - let mut lines: Vec> = Vec::new(); - let mut has_emitted_lines = false; - - for cell in cells { - let mut cell_lines = cell.display_lines(transcript_area.width); - if cell_lines.is_empty() { - continue; - } - - if !cell.is_stream_continuation() { - if has_emitted_lines { - lines.push(Line::from("")); - } else { - has_emitted_lines = true; - } - } - - lines.append(&mut cell_lines); - } + let (lines, meta) = build_transcript_lines(cells, transcript_area.width); if lines.is_empty() { + Clear.render_ref(transcript_area, frame.buffer); + self.transcript_scroll = TranscriptScroll::ToBottom; return; } - Clear.render_ref(transcript_area, frame.buffer); - let total_lines = lines.len(); let max_visible = transcript_area.height as usize; - let start_index = total_lines.saturating_sub(max_visible); + let max_start = total_lines.saturating_sub(max_visible); + + let top_offset = match self.transcript_scroll { + TranscriptScroll::ToBottom => max_start, + TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + } => { + let mut anchor = None; + for (idx, entry) in meta.iter().enumerate() { + if let Some((ci, li)) = entry { + if *ci == cell_index && *li == line_in_cell { + anchor = Some(idx); + break; + } + } + } + if let Some(idx) = anchor { + idx.min(max_start) + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + max_start + } + } + }; + + Clear.render_ref(transcript_area, frame.buffer); - for (row_index, line_index) in (start_index..total_lines).enumerate() { + for (row_index, line_index) in (top_offset..total_lines).enumerate() { if row_index >= max_visible { break; } @@ -549,6 +568,120 @@ pub(crate) fn render_transcript_cells( } } + fn handle_mouse_event(&mut self, tui: &mut tui::Tui, event: crossterm::event::MouseEvent) { + if self.overlay.is_some() { + return; + } + match event.kind { + crossterm::event::MouseEventKind::ScrollUp => { + self.scroll_transcript(tui, -3); + } + crossterm::event::MouseEventKind::ScrollDown => { + self.scroll_transcript(tui, 3); + } + _ => {} + } + } + + fn scroll_transcript(&mut self, tui: &tui::Tui, delta_lines: isize) { + if self.transcript_cells.is_empty() { + return; + } + + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(width); + if chat_height >= height { + return; + } + + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return; + } + + let visible_lines = transcript_height as usize; + if visible_lines == 0 { + return; + } + + let (lines, meta) = build_transcript_lines(&self.transcript_cells, width); + let total_lines = lines.len(); + if total_lines <= visible_lines { + self.transcript_scroll = TranscriptScroll::ToBottom; + return; + } + + let max_start = total_lines.saturating_sub(visible_lines); + + let current_top = match self.transcript_scroll { + TranscriptScroll::ToBottom => max_start, + TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + } => { + let mut anchor = None; + for (idx, entry) in meta.iter().enumerate() { + if let Some((ci, li)) = entry { + if *ci == cell_index && *li == line_in_cell { + anchor = Some(idx); + break; + } + } + } + anchor.unwrap_or(max_start).min(max_start) + } + }; + + if delta_lines == 0 { + return; + } + + let new_top = if delta_lines < 0 { + current_top.saturating_sub(delta_lines.unsigned_abs() as usize) + } else { + current_top.saturating_add(delta_lines as usize).min(max_start) + }; + + if new_top == max_start { + self.transcript_scroll = TranscriptScroll::ToBottom; + } else { + let mut anchor = None; + for idx in new_top..meta.len() { + if let Some((ci, li)) = meta[idx] { + anchor = Some((ci, li)); + break; + } + } + if let Some((cell_index, line_in_cell)) = anchor { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } else if let Some(prev_idx) = + (0..=new_top).rfind(|&idx| meta[idx].is_some()) + { + if let Some((cell_index, line_in_cell)) = meta[prev_idx] { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + } + } else { + self.transcript_scroll = TranscriptScroll::ToBottom; + } + } + + tui.frame_requester().schedule_frame(); + } + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => { @@ -1114,6 +1247,38 @@ fn migration_prompt_allows_auth_mode( } } +fn build_transcript_lines( + cells: &[Arc], + width: u16, +) -> (Vec>, Vec>) { + let mut lines: Vec> = Vec::new(); + let mut meta: Vec> = Vec::new(); + let mut has_emitted_lines = false; + + for (cell_index, cell) in cells.iter().enumerate() { + let mut cell_lines = cell.display_lines(width); + if cell_lines.is_empty() { + continue; + } + + if !cell.is_stream_continuation() { + if has_emitted_lines { + lines.push(Line::from("")); + meta.push(None); + } else { + has_emitted_lines = true; + } + } + + for (line_in_cell, line) in cell_lines.into_iter().enumerate() { + meta.push(Some((cell_index, line_in_cell))); + lines.push(line); + } + } + + (lines, meta) +} + #[cfg(test)] mod tests { use super::*; From 4abba3b1c0fcae253b5be920acca1d99b6fbf0bb Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Mon, 1 Dec 2025 17:52:01 -0800 Subject: [PATCH 06/20] feat: add mouse selection for transcript - Track a TranscriptSelection anchor/head in App so the transcript can be selected with the mouse. - Render the active selection by applying a reversed style to the selected text region, skipping the left gutter. - Extend handle_mouse_event to clamp positions into the transcript area, clear selection on wheel scroll, and support click-drag selection. --- codex-rs/tui/src/app.rs | 140 +++++++++++++++++++++++++++++++++++++++- codex-rs/tui/src/tui.rs | 1 - 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e33e5f2926a..7794a878ede 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -43,8 +43,11 @@ use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::event::MouseButton; use ratatui::layout::Rect; +use ratatui::style::Modifier; use ratatui::style::Stylize; +use ratatui::buffer::Buffer; use ratatui::text::Line; use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; @@ -210,6 +213,7 @@ pub(crate) struct App { pub(crate) transcript_cells: Vec>, transcript_scroll: TranscriptScroll, + transcript_selection: TranscriptSelection, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -250,6 +254,12 @@ impl Default for TranscriptScroll { } } +#[derive(Debug, Clone, Copy, Default)] +struct TranscriptSelection { + anchor: Option<(u16, u16)>, + head: Option<(u16, u16)>, +} + impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { @@ -349,6 +359,7 @@ impl App { enhanced_keys_supported, transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -566,19 +577,146 @@ impl App { }; lines[line_index].render_ref(row_area, frame.buffer); } + + self.apply_transcript_selection(transcript_area, frame.buffer); + } + + fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) { + let (anchor, head) = match (self.transcript_selection.anchor, self.transcript_selection.head) + { + (Some(a), Some(h)) => (a, h), + _ => return, + }; + + let mut start = anchor; + let mut end = head; + if (end.1 < start.1) || (end.1 == start.1 && end.0 < start.0) { + std::mem::swap(&mut start, &mut end); + } + + let base_x = area.x.saturating_add(2); + let max_x = area.right().saturating_sub(1); + + for y in area.y..area.bottom() { + if y < start.1 || y > end.1 { + continue; + } + + let mut first_text_x = None; + let mut last_text_x = None; + for x in base_x..=max_x { + let cell = &buf[(x, y)]; + if cell.symbol() != " " { + if first_text_x.is_none() { + first_text_x = Some(x); + } + last_text_x = Some(x); + } + } + + let (text_start, text_end) = match (first_text_x, last_text_x) { + (Some(s), Some(e)) => (s, e), + _ => continue, + }; + + let row_sel_start = if y == start.1 { + start.0.max(base_x) + } else { + base_x + }; + let row_sel_end = if y == end.1 { + end.0.min(max_x) + } else { + max_x + }; + + if row_sel_start > row_sel_end { + continue; + } + + let from_x = row_sel_start.max(text_start); + let to_x = row_sel_end.min(text_end); + + if from_x > to_x { + continue; + } + + for x in from_x..=to_x { + let cell = &mut buf[(x, y)]; + let style = cell.style(); + cell.set_style(style.add_modifier(Modifier::REVERSED)); + } + } } fn handle_mouse_event(&mut self, tui: &mut tui::Tui, event: crossterm::event::MouseEvent) { if self.overlay.is_some() { return; } + + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(width); + if chat_height >= height { + return; + } + + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return; + } + + let transcript_area = Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }; + + let base_x = transcript_area.x.saturating_add(2); + let max_x = transcript_area.right().saturating_sub(1); + + let mut clamped_x = event.column; + let mut clamped_y = event.row; + + if clamped_y < transcript_area.y || clamped_y >= transcript_area.bottom() { + clamped_y = transcript_area.y; + } + if clamped_x < base_x { + clamped_x = base_x; + } + if clamped_x > max_x { + clamped_x = max_x; + } + match event.kind { crossterm::event::MouseEventKind::ScrollUp => { + self.transcript_selection = TranscriptSelection::default(); self.scroll_transcript(tui, -3); } crossterm::event::MouseEventKind::ScrollDown => { + self.transcript_selection = TranscriptSelection::default(); self.scroll_transcript(tui, 3); } + crossterm::event::MouseEventKind::Down(MouseButton::Left) => { + self.transcript_selection.anchor = Some((clamped_x, clamped_y)); + self.transcript_selection.head = Some((clamped_x, clamped_y)); + } + crossterm::event::MouseEventKind::Drag(MouseButton::Left) => { + if self.transcript_selection.anchor.is_some() { + self.transcript_selection.head = Some((clamped_x, clamped_y)); + } + } + crossterm::event::MouseEventKind::Up(MouseButton::Left) => { + if self.transcript_selection.anchor == self.transcript_selection.head { + self.transcript_selection = TranscriptSelection::default(); + } + } _ => {} } } @@ -1256,7 +1394,7 @@ fn build_transcript_lines( let mut has_emitted_lines = false; for (cell_index, cell) in cells.iter().enumerate() { - let mut cell_lines = cell.display_lines(width); + let cell_lines = cell.display_lines(width); if cell_lines.is_empty() { continue; } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 5edcc764611..1c0892e6862 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -274,7 +274,6 @@ impl Tui { Event::FocusLost => { terminal_focused.store(false, Ordering::Relaxed); } - _ => {} } } result = draw_rx.recv() => { From 2f20caacc4b38aed76672c063a9019bc9b111038 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 10:53:43 -0800 Subject: [PATCH 07/20] feat: surface transcript scroll and copy hints - Track transcript_view_top and treat transcript scrolling as content-relative so the selection and view stay aligned with history cells. - Detect user history cells when rendering and paint the entire row with the user_message_style background for better prompt highlighting. - Thread transcript scrolled/selection state into the bottom pane footer, showing scrollback and Ctrl+Y copy hints when appropriate. --- codex-rs/tui/src/app.rs | 94 ++++++++++++++----- codex-rs/tui/src/bottom_pane/chat_composer.rs | 11 +++ codex-rs/tui/src/bottom_pane/footer.rs | 25 +++++ codex-rs/tui/src/bottom_pane/mod.rs | 6 ++ codex-rs/tui/src/chatwidget.rs | 9 ++ 5 files changed, 121 insertions(+), 24 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7794a878ede..f4f28d04d80 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -8,6 +8,7 @@ use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; use crate::history_cell::HistoryCell; +use crate::history_cell::UserHistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_config; use crate::model_migration::run_model_migration_prompt; @@ -44,10 +45,10 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::MouseButton; +use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Modifier; use ratatui::style::Stylize; -use ratatui::buffer::Buffer; use ratatui::text::Line; use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; @@ -214,6 +215,7 @@ pub(crate) struct App { pub(crate) transcript_cells: Vec>, transcript_scroll: TranscriptScroll, transcript_selection: TranscriptSelection, + transcript_view_top: usize, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -256,8 +258,8 @@ impl Default for TranscriptScroll { #[derive(Debug, Clone, Copy, Default)] struct TranscriptSelection { - anchor: Option<(u16, u16)>, - head: Option<(u16, u16)>, + anchor: Option<(usize, u16)>, + head: Option<(usize, u16)>, } impl App { @@ -360,6 +362,7 @@ impl App { transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::ToBottom, transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -452,6 +455,15 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { + let transcript_scrolled = + !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); + let selection_active = matches!( + (self.transcript_selection.anchor, self.transcript_selection.head), + (Some(a), Some(b)) if a != b + ); + self.chat_widget + .set_transcript_ui_state(transcript_scrolled, selection_active); + self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget @@ -516,6 +528,7 @@ impl App { frame.buffer, ); self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; return; } @@ -528,6 +541,11 @@ impl App { let (lines, meta) = build_transcript_lines(cells, transcript_area.width); + let is_user_cell: Vec = cells + .iter() + .map(|c| c.as_any().is::()) + .collect(); + if lines.is_empty() { Clear.render_ref(transcript_area, frame.buffer); self.transcript_scroll = TranscriptScroll::ToBottom; @@ -561,6 +579,7 @@ impl App { } } }; + self.transcript_view_top = top_offset; Clear.render_ref(transcript_area, frame.buffer); @@ -575,6 +594,16 @@ impl App { width: transcript_area.width, height: 1, }; + if let Some((cell_index, _)) = meta[line_index] { + if is_user_cell.get(cell_index).copied().unwrap_or(false) { + let base_style = crate::style::user_message_style(); + for x in row_area.x..row_area.right() { + let cell = &mut frame.buffer[(x, y)]; + let style = cell.style().patch(base_style); + cell.set_style(style); + } + } + } lines[line_index].render_ref(row_area, frame.buffer); } @@ -582,23 +611,30 @@ impl App { } fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) { - let (anchor, head) = match (self.transcript_selection.anchor, self.transcript_selection.head) - { + let (anchor, head) = match ( + self.transcript_selection.anchor, + self.transcript_selection.head, + ) { (Some(a), Some(h)) => (a, h), _ => return, }; - let mut start = anchor; - let mut end = head; - if (end.1 < start.1) || (end.1 == start.1 && end.0 < start.0) { - std::mem::swap(&mut start, &mut end); + let (mut start_line, mut start_col) = anchor; + let (mut end_line, mut end_col) = head; + if (end_line < start_line) || (end_line == start_line && end_col < start_col) { + std::mem::swap(&mut start_line, &mut end_line); + std::mem::swap(&mut start_col, &mut end_col); } let base_x = area.x.saturating_add(2); let max_x = area.right().saturating_sub(1); + let top_offset = self.transcript_view_top; for y in area.y..area.bottom() { - if y < start.1 || y > end.1 { + let row_index = usize::from(y.saturating_sub(area.y)); + let line_index = top_offset.saturating_add(row_index); + + if line_index < start_line || line_index > end_line { continue; } @@ -619,13 +655,13 @@ impl App { _ => continue, }; - let row_sel_start = if y == start.1 { - start.0.max(base_x) + let row_sel_start = if line_index == start_line { + start_col.max(base_x) } else { base_x }; - let row_sel_end = if y == end.1 { - end.0.min(max_x) + let row_sel_end = if line_index == end_line { + end_col.min(max_x) } else { max_x }; @@ -694,22 +730,28 @@ impl App { clamped_x = max_x; } + let streaming = self.chat_widget.is_task_running() + && matches!(self.transcript_scroll, TranscriptScroll::ToBottom); + + let row_index = usize::from(clamped_y.saturating_sub(transcript_area.y)); + let line_index = self.transcript_view_top.saturating_add(row_index); + match event.kind { crossterm::event::MouseEventKind::ScrollUp => { - self.transcript_selection = TranscriptSelection::default(); self.scroll_transcript(tui, -3); } crossterm::event::MouseEventKind::ScrollDown => { - self.transcript_selection = TranscriptSelection::default(); self.scroll_transcript(tui, 3); } crossterm::event::MouseEventKind::Down(MouseButton::Left) => { - self.transcript_selection.anchor = Some((clamped_x, clamped_y)); - self.transcript_selection.head = Some((clamped_x, clamped_y)); + if !streaming { + self.transcript_selection.anchor = Some((line_index, clamped_x)); + self.transcript_selection.head = Some((line_index, clamped_x)); + } } crossterm::event::MouseEventKind::Drag(MouseButton::Left) => { - if self.transcript_selection.anchor.is_some() { - self.transcript_selection.head = Some((clamped_x, clamped_y)); + if !streaming && self.transcript_selection.anchor.is_some() { + self.transcript_selection.head = Some((line_index, clamped_x)); } } crossterm::event::MouseEventKind::Up(MouseButton::Left) => { @@ -783,7 +825,9 @@ impl App { let new_top = if delta_lines < 0 { current_top.saturating_sub(delta_lines.unsigned_abs() as usize) } else { - current_top.saturating_add(delta_lines as usize).min(max_start) + current_top + .saturating_add(delta_lines as usize) + .min(max_start) }; if new_top == max_start { @@ -801,9 +845,7 @@ impl App { cell_index, line_in_cell, }; - } else if let Some(prev_idx) = - (0..=new_top).rfind(|&idx| meta[idx].is_some()) - { + } else if let Some(prev_idx) = (0..=new_top).rfind(|&idx| meta[idx].is_some()) { if let Some((cell_index, line_in_cell)) = meta[prev_idx] { self.transcript_scroll = TranscriptScroll::Scrolled { cell_index, @@ -1462,6 +1504,8 @@ mod tests { file_search, transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -1500,6 +1544,8 @@ mod tests { file_search, transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::ToBottom, + transcript_selection: TranscriptSelection::default(), + transcript_view_top: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6b42e5134c9..e04961ba5a1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -113,6 +113,8 @@ pub(crate) struct ChatComposer { footer_mode: FooterMode, footer_hint_override: Option>, context_window_percent: Option, + transcript_scrolled: bool, + transcript_selection_active: bool, } /// Popup state – at most one can be visible at any time. @@ -156,6 +158,8 @@ impl ChatComposer { footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -1387,6 +1391,8 @@ impl ChatComposer { use_shift_enter_hint: self.use_shift_enter_hint, is_task_running: self.is_task_running, context_window_percent: self.context_window_percent, + transcript_scrolled: self.transcript_scrolled, + transcript_selection_active: self.transcript_selection_active, } } @@ -1517,6 +1523,11 @@ impl ChatComposer { self.is_task_running = running; } + pub(crate) fn set_transcript_ui_state(&mut self, scrolled: bool, selection_active: bool) { + self.transcript_scrolled = scrolled; + self.transcript_selection_active = selection_active; + } + pub(crate) fn set_context_window_percent(&mut self, percent: Option) { if self.context_window_percent != percent { self.context_window_percent = percent; diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 79d7c60fa70..c0252b42675 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -18,6 +18,8 @@ pub(crate) struct FooterProps { pub(crate) use_shift_enter_hint: bool, pub(crate) is_task_running: bool, pub(crate) context_window_percent: Option, + pub(crate) transcript_scrolled: bool, + pub(crate) transcript_selection_active: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -87,6 +89,15 @@ fn footer_lines(props: FooterProps) -> Vec> { key_hint::plain(KeyCode::Char('?')).into(), " for shortcuts".dim(), ]); + if props.transcript_scrolled { + line.push_span(" · ".dim()); + line.push_span("scrollback".dim()); + } + if props.transcript_selection_active { + line.push_span(" · ".dim()); + line.push_span(key_hint::ctrl(KeyCode::Char('y'))); + line.push_span(" copy selection".dim()); + } vec![line] } FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { @@ -400,6 +411,8 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, }, ); @@ -411,6 +424,8 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, }, ); @@ -422,6 +437,8 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, }, ); @@ -433,6 +450,8 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, }, ); @@ -444,6 +463,8 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, }, ); @@ -455,6 +476,8 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, }, ); @@ -466,6 +489,8 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: Some(72), + transcript_scrolled: false, + transcript_selection_active: false, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5dbfb210b24..35cc3fc4f7d 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -366,6 +366,12 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_transcript_ui_state(&mut self, scrolled: bool, selection_active: bool) { + self.composer + .set_transcript_ui_state(scrolled, selection_active); + self.request_redraw(); + } + /// Update custom prompts available for the slash popup. pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.composer.set_custom_prompts(prompts); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fbabbc300e9..f0ace78a24e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2845,6 +2845,15 @@ impl ChatWidget { pub(crate) fn clear_esc_backtrack_hint(&mut self) { self.bottom_pane.clear_esc_backtrack_hint(); } + + pub(crate) fn is_task_running(&self) -> bool { + self.bottom_pane.is_task_running() + } + + pub(crate) fn set_transcript_ui_state(&mut self, scrolled: bool, selection_active: bool) { + self.bottom_pane + .set_transcript_ui_state(scrolled, selection_active); + } /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { // Record outbound operation for session replay fidelity. From f9d71f35888df1c65eb2f4a894ee78d9d4bb5c76 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 11:25:17 -0800 Subject: [PATCH 08/20] feat: add keyboard shortcuts for transcript scroll - Map PageUp/PageDown to scroll the transcript by a full transcript viewport, independent of the composer height. - Map Home/End to jump to the top or back to the bottom of the transcript history. - Extend the footer shortcut summary to advertise PgUp/PgDn scroll and Home/End jump when the transcript is scrolled. --- codex-rs/tui/src/app.rs | 63 ++++++++++++++++++++++++++ codex-rs/tui/src/bottom_pane/footer.rs | 9 +++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f4f28d04d80..109b2cfd43b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1358,6 +1358,69 @@ impl App { // Delegate to helper for clarity; preserves behavior. self.confirm_backtrack_from_main(); } + KeyEvent { + code: KeyCode::PageUp, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width > 0 && height > 0 { + let chat_height = self.chat_widget.desired_height(width); + if chat_height < height { + let transcript_height = height.saturating_sub(chat_height); + if transcript_height > 0 { + self.scroll_transcript( + tui, + -(isize::try_from(transcript_height).unwrap_or(isize::MAX)), + ); + } + } + } + } + KeyEvent { + code: KeyCode::PageDown, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width > 0 && height > 0 { + let chat_height = self.chat_widget.desired_height(width); + if chat_height < height { + let transcript_height = height.saturating_sub(chat_height); + if transcript_height > 0 { + self.scroll_transcript( + tui, + isize::try_from(transcript_height).unwrap_or(isize::MAX), + ); + } + } + } + } + KeyEvent { + code: KeyCode::Home, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + if !self.transcript_cells.is_empty() { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index: 0, + line_in_cell: 0, + }; + tui.frame_requester().schedule_frame(); + } + } + KeyEvent { + code: KeyCode::End, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.transcript_scroll = TranscriptScroll::ToBottom; + tui.frame_requester().schedule_frame(); + } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index c0252b42675..fa06411c3d8 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -91,7 +91,14 @@ fn footer_lines(props: FooterProps) -> Vec> { ]); if props.transcript_scrolled { line.push_span(" · ".dim()); - line.push_span("scrollback".dim()); + line.push_span(key_hint::plain(KeyCode::PageUp)); + line.push_span("/"); + line.push_span(key_hint::plain(KeyCode::PageDown)); + line.push_span(" scroll · ".dim()); + line.push_span(key_hint::plain(KeyCode::Home)); + line.push_span("/"); + line.push_span(key_hint::plain(KeyCode::End)); + line.push_span(" jump".dim()); } if props.transcript_selection_active { line.push_span(" · ".dim()); From 27265cae801143a88fe257402c806294113ce808 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 11:51:14 -0800 Subject: [PATCH 09/20] feat: show transcript scroll position in footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track transcript_total_lines and transcript_view_top in App so we can derive the current visible top line. - Thread an optional (current, total) scroll_position through ChatWidget and BottomPane into FooterProps. - Render a dimmed “current/total” indicator in the footer when the transcript is scrolled to give users a sense of position. --- codex-rs/tui/src/app.rs | 37 ++++++++++++++----- codex-rs/tui/src/bottom_pane/chat_composer.rs | 11 +++++- codex-rs/tui/src/bottom_pane/footer.rs | 12 ++++++ codex-rs/tui/src/bottom_pane/mod.rs | 9 ++++- codex-rs/tui/src/chatwidget.rs | 9 ++++- 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 109b2cfd43b..4fea0bb9746 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -216,6 +216,7 @@ pub(crate) struct App { transcript_scroll: TranscriptScroll, transcript_selection: TranscriptSelection, transcript_view_top: usize, + transcript_total_lines: usize, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -363,6 +364,7 @@ impl App { transcript_scroll: TranscriptScroll::ToBottom, transcript_selection: TranscriptSelection::default(), transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -455,15 +457,6 @@ impl App { self.chat_widget.handle_paste(pasted); } TuiEvent::Draw => { - let transcript_scrolled = - !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); - let selection_active = matches!( - (self.transcript_selection.anchor, self.transcript_selection.head), - (Some(a), Some(b)) if a != b - ); - self.chat_widget - .set_transcript_ui_state(transcript_scrolled, selection_active); - self.chat_widget.maybe_post_pending_notification(tui); if self .chat_widget @@ -489,6 +482,26 @@ impl App { frame.set_cursor_position((x, y)); } self.render_transcript_cells(frame, &cells); + + let transcript_scrolled = + !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); + let selection_active = matches!( + (self.transcript_selection.anchor, self.transcript_selection.head), + (Some(a), Some(b)) if a != b + ); + let scroll_position = if self.transcript_total_lines == 0 { + None + } else { + Some(( + self.transcript_view_top.saturating_add(1), + self.transcript_total_lines, + )) + }; + self.chat_widget.set_transcript_ui_state( + transcript_scrolled, + selection_active, + scroll_position, + ); }, )?; } @@ -529,6 +542,7 @@ impl App { ); self.transcript_scroll = TranscriptScroll::ToBottom; self.transcript_view_top = 0; + self.transcript_total_lines = 0; return; } @@ -549,10 +563,13 @@ impl App { if lines.is_empty() { Clear.render_ref(transcript_area, frame.buffer); self.transcript_scroll = TranscriptScroll::ToBottom; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; return; } let total_lines = lines.len(); + self.transcript_total_lines = total_lines; let max_visible = transcript_area.height as usize; let max_start = total_lines.saturating_sub(max_visible); @@ -1569,6 +1586,7 @@ mod tests { transcript_scroll: TranscriptScroll::ToBottom, transcript_selection: TranscriptSelection::default(), transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -1609,6 +1627,7 @@ mod tests { transcript_scroll: TranscriptScroll::ToBottom, transcript_selection: TranscriptSelection::default(), transcript_view_top: 0, + transcript_total_lines: 0, overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e04961ba5a1..4c435fe67a6 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -115,6 +115,7 @@ pub(crate) struct ChatComposer { context_window_percent: Option, transcript_scrolled: bool, transcript_selection_active: bool, + transcript_scroll_position: Option<(usize, usize)>, } /// Popup state – at most one can be visible at any time. @@ -160,6 +161,7 @@ impl ChatComposer { context_window_percent: None, transcript_scrolled: false, transcript_selection_active: false, + transcript_scroll_position: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -1393,6 +1395,7 @@ impl ChatComposer { context_window_percent: self.context_window_percent, transcript_scrolled: self.transcript_scrolled, transcript_selection_active: self.transcript_selection_active, + transcript_scroll_position: self.transcript_scroll_position, } } @@ -1523,9 +1526,15 @@ impl ChatComposer { self.is_task_running = running; } - pub(crate) fn set_transcript_ui_state(&mut self, scrolled: bool, selection_active: bool) { + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { self.transcript_scrolled = scrolled; self.transcript_selection_active = selection_active; + self.transcript_scroll_position = scroll_position; } pub(crate) fn set_context_window_percent(&mut self, percent: Option) { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index fa06411c3d8..c61fe05bb99 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -20,6 +20,7 @@ pub(crate) struct FooterProps { pub(crate) context_window_percent: Option, pub(crate) transcript_scrolled: bool, pub(crate) transcript_selection_active: bool, + pub(crate) transcript_scroll_position: Option<(usize, usize)>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -99,6 +100,10 @@ fn footer_lines(props: FooterProps) -> Vec> { line.push_span("/"); line.push_span(key_hint::plain(KeyCode::End)); line.push_span(" jump".dim()); + if let Some((current, total)) = props.transcript_scroll_position { + line.push_span(" · ".dim()); + line.push_span(Span::from(format!("{current}/{total}")).dim()); + } } if props.transcript_selection_active { line.push_span(" · ".dim()); @@ -420,6 +425,7 @@ mod tests { context_window_percent: None, transcript_scrolled: false, transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -433,6 +439,7 @@ mod tests { context_window_percent: None, transcript_scrolled: false, transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -446,6 +453,7 @@ mod tests { context_window_percent: None, transcript_scrolled: false, transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -459,6 +467,7 @@ mod tests { context_window_percent: None, transcript_scrolled: false, transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -472,6 +481,7 @@ mod tests { context_window_percent: None, transcript_scrolled: false, transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -485,6 +495,7 @@ mod tests { context_window_percent: None, transcript_scrolled: false, transcript_selection_active: false, + transcript_scroll_position: None, }, ); @@ -498,6 +509,7 @@ mod tests { context_window_percent: Some(72), transcript_scrolled: false, transcript_selection_active: false, + transcript_scroll_position: None, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 35cc3fc4f7d..d472d4a598b 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -366,9 +366,14 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn set_transcript_ui_state(&mut self, scrolled: bool, selection_active: bool) { + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { self.composer - .set_transcript_ui_state(scrolled, selection_active); + .set_transcript_ui_state(scrolled, selection_active, scroll_position); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f0ace78a24e..d1310a8beb4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2850,9 +2850,14 @@ impl ChatWidget { self.bottom_pane.is_task_running() } - pub(crate) fn set_transcript_ui_state(&mut self, scrolled: bool, selection_active: bool) { + pub(crate) fn set_transcript_ui_state( + &mut self, + scrolled: bool, + selection_active: bool, + scroll_position: Option<(usize, usize)>, + ) { self.bottom_pane - .set_transcript_ui_state(scrolled, selection_active); + .set_transcript_ui_state(scrolled, selection_active, scroll_position); } /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { From 08436aef300a7124ffbac049ea61f672e86c5169 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 12:24:50 -0800 Subject: [PATCH 10/20] docs: describe streaming markdown wrapping - Add streaming_wrapping_design.md to capture how streaming agent responses are rendered and wrapped today. - Document why streamed markdown does not reflow correctly on resize due to pre-wrapping in MarkdownStreamCollector. - Outline several design directions (width=None, streaming cell, hybrid visual-line model) and recommend starting with a minimal width=None change. --- codex-rs/tui/streaming_wrapping_design.md | 526 ++++++++++++++++++++++ 1 file changed, 526 insertions(+) create mode 100644 codex-rs/tui/streaming_wrapping_design.md diff --git a/codex-rs/tui/streaming_wrapping_design.md b/codex-rs/tui/streaming_wrapping_design.md new file mode 100644 index 00000000000..04511f98fe4 --- /dev/null +++ b/codex-rs/tui/streaming_wrapping_design.md @@ -0,0 +1,526 @@ +# Streaming Markdown Wrapping & Animation – Design Notes + +This document describes a rendering quirk in the Codex TUI when displaying +**streaming agent responses**, and outlines options for improving it. + +The goal is to give enough context that someone who doesn’t live in this code +can understand: + +- Where streaming rendering happens +- Why streamed content doesn’t reflow when the terminal width changes +- What behaviors we must preserve (newlines, lists, blockquotes, headings, + animation) +- Possible directions for a more robust design + +--- + +## 1. Problem Overview + +When the model streams an answer, the TUI shows it incrementally, animating out +new lines as they arrive. The streaming pipeline currently **wraps lines at a +fixed width at commit time**, and those wraps are baked into the stored +`Line<'static>` values. + +Later, when the terminal is resized or the viewport changes, the transcript +rendering code wraps again for the new width, but it can only insert *additional* +breaks. It cannot “un‑wrap” the splits that were baked in when the stream +started. The result: + +- Streamed messages **do not reflow** correctly when the terminal width grows. +- Long paragraphs are permanently split according to the width at the time they + were streamed. + +Non‑streaming messages don’t have this issue; they are rendered without a +width and wrapped only at display time, so they reflow correctly. + +--- + +## 2. Current Streaming Rendering Flow + +This section walks through the main pieces involved in rendering a streaming +agent result. + +### 2.1 ChatWidget: handling streaming deltas + +Streaming agent content arrives as deltas and is handled in +`ChatWidget::handle_streaming_delta`: + +```rust +// codex-rs/tui/src/chatwidget.rs:940+ +#[inline] +fn handle_streaming_delta(&mut self, delta: String) { + // Before streaming agent content, flush any active exec cell group. + self.flush_active_cell(); + + if self.stream_controller.is_none() { + if self.needs_final_message_separator { + let elapsed_seconds = self + .bottom_pane + .status_widget() + .map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds); + self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); + self.needs_final_message_separator = false; + } + self.stream_controller = Some(StreamController::new( + self.last_rendered_width.get().map(|w| w.saturating_sub(2)), + )); + } + if let Some(controller) = self.stream_controller.as_mut() + && controller.push(&delta) + { + self.app_event_tx.send(AppEvent::StartCommitAnimation); + } + self.request_redraw(); +} +``` + +Key points: + +- On the **first** delta of a stream, we create a `StreamController`, passing a + width based on the **last rendered width of the main view** (minus some + padding). +- Every delta is pushed into the controller; if `push` returns `true`, we start + the commit animation. + +### 2.2 StreamController and StreamState + +The controller ties together streaming state and the commit‑animation loop: + +```rust +// codex-rs/tui/src/streaming/controller.rs +pub(crate) struct StreamController { + state: StreamState, + finishing_after_drain: bool, + header_emitted: bool, +} + +impl StreamController { + pub(crate) fn new(width: Option) -> Self { + Self { + state: StreamState::new(width), + finishing_after_drain: false, + header_emitted: false, + } + } + + pub(crate) fn push(&mut self, delta: &str) -> bool { + let state = &mut self.state; + if !delta.is_empty() { + state.has_seen_delta = true; + } + state.collector.push_delta(delta); + if delta.contains('\n') { + let newly_completed = state.collector.commit_complete_lines(); + if !newly_completed.is_empty() { + state.enqueue(newly_completed); + return true; + } + } + false + } + + pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { + let step = self.state.step(); + (self.emit(step), self.state.is_idle()) + } +} +``` + +The associated `StreamState` holds a `MarkdownStreamCollector` and a queue of +rendered lines: + +```rust +// codex-rs/tui/src/streaming/mod.rs +pub(crate) struct StreamState { + pub(crate) collector: MarkdownStreamCollector, + queued_lines: VecDeque>, + pub(crate) has_seen_delta: bool, +} + +impl StreamState { + pub(crate) fn new(width: Option) -> Self { + Self { + collector: MarkdownStreamCollector::new(width), + queued_lines: VecDeque::new(), + has_seen_delta: false, + } + } + // step / drain_all / enqueue just move Line<'static> values around. +} +``` + +### 2.3 MarkdownStreamCollector: width‑aware streaming and commits + +The collector is where markdown is rendered and **width‑dependent wrapping** +happens during streaming: + +```rust +// codex-rs/tui/src/markdown_stream.rs +pub(crate) struct MarkdownStreamCollector { + buffer: String, + committed_line_count: usize, + width: Option, +} + +impl MarkdownStreamCollector { + pub fn new(width: Option) -> Self { ... } + + pub fn push_delta(&mut self, delta: &str) { + self.buffer.push_str(delta); + } + + pub fn commit_complete_lines(&mut self) -> Vec> { + let source = self.buffer.clone(); + let last_newline_idx = source.rfind('\n'); + let source = if let Some(last_newline_idx) = last_newline_idx { + source[..=last_newline_idx].to_string() + } else { + return Vec::new(); + }; + let mut rendered: Vec> = Vec::new(); + markdown::append_markdown(&source, self.width, &mut rendered); + let mut complete_line_count = rendered.len(); + // trim trailing blank line + if complete_line_count > 0 + && crate::render::line_utils::is_blank_line_spaces_only( + &rendered[complete_line_count - 1], + ) + { + complete_line_count -= 1; + } + + if self.committed_line_count >= complete_line_count { + return Vec::new(); + } + + let out_slice = &rendered[self.committed_line_count..complete_line_count]; + + let out = out_slice.to_vec(); + self.committed_line_count = complete_line_count; + out + } +} +``` + +Important details: + +- The collector owns the **full raw markdown buffer** for the active stream. +- It calls `append_markdown(&source, self.width, &mut rendered)`, passing the + width that was captured when the stream started. +- It tracks `committed_line_count` in terms of the **number of rendered lines** + (after wrapping), and returns only the *new* rendered lines as + `Vec>`. + +### 2.4 markdown::append_markdown and markdown_render + +`append_markdown` is a thin wrapper around the markdown renderer: + +```rust +// codex-rs/tui/src/markdown.rs +pub(crate) fn append_markdown( + markdown_source: &str, + width: Option, + lines: &mut Vec>, +) { + let rendered = crate::markdown_render::render_markdown_text_with_width(markdown_source, width); + crate::render::line_utils::push_owned_lines(&rendered.lines, lines); +} +``` + +Inside `markdown_render`, the width is used to decide whether to wrap each line: + +```rust +// codex-rs/tui/src/markdown_render.rs:444+ +fn flush_current_line(&mut self) { + if let Some(line) = self.current_line_content.take() { + let style = self.current_line_style; + // NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste. + if !self.current_line_in_code_block + && let Some(width) = self.wrap_width + { + let opts = RtOptions::new(width) + .initial_indent(self.current_initial_indent.clone().into()) + .subsequent_indent(self.current_subsequent_indent.clone().into()); + for wrapped in word_wrap_line(&line, opts) { + let owned = line_to_static(&wrapped).style(style); + self.text.lines.push(owned); + } + } else { + let mut spans = self.current_initial_indent.clone(); + let mut line = line; + spans.append(&mut line.spans); + self.text.lines.push(Line::from_iter(spans).style(style)); + } + // ... + } +} +``` + +So the collector is not just parsing markdown; it’s producing **fully wrapped** +lines, complete with list/blockquote prefixes and styles, at a fixed width for +this stream. + +### 2.5 Emitting HistoryCells and final display + +`StreamController::emit` turns those wrapped lines into an `AgentMessageCell`: + +```rust +// codex-rs/tui/src/streaming/controller.rs:75+ +fn emit(&mut self, lines: Vec>) -> Option> { + if lines.is_empty() { + return None; + } + Some(Box::new(history_cell::AgentMessageCell::new(lines, { + let header_emitted = self.header_emitted; + self.header_emitted = true; + !header_emitted + }))) +} +``` + +`AgentMessageCell`’s `HistoryCell` implementation wraps **again** at display time: + +```rust +// codex-rs/tui/src/history_cell.rs:236+ +impl HistoryCell for AgentMessageCell { + fn display_lines(&self, width: u16) -> Vec> { + word_wrap_lines( + &self.lines, + RtOptions::new(width as usize) + .initial_indent(if self.is_first_line { + "• ".dim().into() + } else { + " ".into() + }) + .subsequent_indent(" ".into()), + ) + } +} +``` + +This is the same `display_lines(width)` used by both: + +- The **main transcript area** in `App::render_transcript_cells` + (`codex-rs/tui/src/app.rs`), and +- The transcript overlay (pager) in `pager_overlay.rs`. + +--- + +## 3. Why Streamed Content Doesn’t Reflow + +The key problem is that we are **wrapping twice**, at two different layers: + +1. `MarkdownStreamCollector` wraps logical markdown lines into `Line<'static>`s + at a fixed, per‑stream width, and uses the count of those wrapped lines as + its commit unit. +2. `AgentMessageCell::display_lines(width)` wraps those `Line<'static>`s again + for the current viewport width. + +Once the collector has split a paragraph into multiple lines at the streaming +width, those splits are **baked into `self.lines`** inside `AgentMessageCell`. +Later, when the terminal width increases: + +- `display_lines(new_width)` can break those lines further if needed, +- but it can’t “rejoin” lines that were already split in the collector, + because it has no access to the original unbroken text. + +So streaming content behaves like this: + +- Width at **stream start** defines “hard” line boundaries. +- Width at **render time** can only refine, not undo, those boundaries. + +By contrast, non‑streaming messages are rendered with `width = None` in +`append_markdown`, so their `AgentMessageCell` is seeded with logical lines that +do *not* depend on the viewport. All wrapping is deferred to `display_lines`, +and they reflow correctly when the terminal is resized. + +--- + +## 4. Behaviors We Must Preserve + +Any redesign needs to keep the following stable behaviors: + +1. **Newline‑gated commits.** + - `MarkdownStreamCollector::commit_complete_lines` currently only emits new + content when a newline is seen in the buffer. This keeps streaming output + “coherent” at newline boundaries. +2. **Markdown semantics.** + - Lists, nested lists, blockquotes, headings, and code blocks must continue + to render with the same structure and styling as they do today. That logic + lives in `markdown_render.rs` and is tested via + `markdown_stream.rs` and `markdown_render.rs` tests. +3. **Streaming animation feel.** + - `StreamController::on_commit_tick` currently emits at most one new + `HistoryCell` per tick, revealing the response incrementally. + - The step size is “one wrapped line” at the stream’s width. Changing the + granularity is acceptable, but we should avoid regressing to a single huge + jump per tick for long paragraphs. +4. **History & backtrack integration.** + - The transcript is stored as a sequence of `HistoryCell`s in + `App::transcript_cells` (`app.rs`). + - Backtracking, overlays, and session replay assume that once a cell is + emitted, its content is stable. + +--- + +## 5. Design Direction 1: `width = None` for Streaming + +The simplest change conceptually is to make streaming behave like non‑streaming +markdown: + +- Construct the streaming collector with `MarkdownStreamCollector::new(None)`: + - i.e. **no width** when we call `append_markdown`. +- `MarkdownStreamCollector` would still: + - Buffer the raw markdown, + - Commit only at newline boundaries, + - Return newly completed logical lines, but they would not be wrapped. +- `AgentMessageCell::display_lines(width)` remains the sole place where + wrapping happens, based on the *current* viewport width. + +**Impact:** + +- ✅ Streamed messages would reflow correctly when the terminal size changes + (like non‑streaming messages). +- ✅ Newline‑gated behavior is unchanged; we still commit at newline boundaries. +- ✅ Lists / blockquotes / headings remain correct; tests already exercise the + `width = None` path by streaming into `MarkdownStreamCollector::new(None)` + and wrapping later for assertions. +- ⚠️ Animation granularity changes: + - Today: animation steps per **wrapped line** at the stream’s width. + - With `None`: animation steps per **logical line** (paragraph / list item / + blockquote line), which could be visually larger steps on narrow terminals. + +This is likely an acceptable behavioral change, but it is a tradeoff: +better reflow vs. slightly coarser animation steps. + +--- + +## 6. Design Direction 2: Streaming Cell with “Committed Prefix” + +If we want to keep a **width‑aware sense of progress** (i.e., roughly “one +visual line” per tick) *and* support reflow on resize, we probably need a more +stateful streaming cell type instead of the current `Vec>` +pipeline. + +### 6.1 High‑level idea + +Introduce a `StreamingAgentMessageCell` that owns: + +- The full raw markdown buffer for the message (or a pre‑parsed representation). +- A notion of “how much has been revealed so far”: + - e.g. a byte index `commit_upto` in the source, or a logical line index. +- Optional caches keyed by `(width, commit_upto)` to avoid re‑parsing + everything on every frame. + +`display_lines(width)` for this cell would: + +1. Compute the visible prefix of the message based on `commit_upto`. +2. Render that prefix with `append_markdown(prefix, None, &mut rendered)`. +3. Apply presentation wrapping for `width` (e.g. via `AgentMessageCell`‑like + logic). + +`StreamController` would no longer enqueue pre‑wrapped `Line<'static>` values. +Instead: + +- It would own a single `StreamingAgentMessageCell`. +- `push(delta)` would append to the cell’s buffer, and determine whether a + newline has been added (unchanged gating behavior). +- `on_commit_tick` would advance `commit_upto` by some unit: + - simplest: one logical newline‑terminated line per tick, + - more advanced: enough characters to approximate one visual line at the last + known width. + +Once streaming completes, the cell could be finalized into a plain +`AgentMessageCell` for long‑term storage and simpler replay. + +### 6.2 Challenges + +This direction touches more pieces and has some non‑trivial challenges: + +- **Commit units vs. width.** + Today, `committed_line_count` is “number of rendered lines” at a fixed width. + If we switch to a “committed prefix” model: + - We must decide whether `commit_upto` is tracked in source bytes, logical + lines, or derived from visual wrapping at a given width. + - If visual wrapping is used, changing widths mid‑stream changes the mapping + from “N visual lines” to a source position; we’d need to tolerate that + non‑linearity. + +- **Performance / recomputation.** + On each render we potentially need to: + - Re‑parse markdown for the prefix up to `commit_upto`. + - Re‑wrap for the current width. + - This can be mitigated with simple caching keyed by `(width, commit_upto)`, + but it’s inherently heavier than the current “pre‑wrap once” approach. + +- **History expectations.** + The transcript currently stores a sequence of *static* `HistoryCell`s. + A streaming cell whose content changes (grows) over time is already implied + by how we append streamed agent content, but we need to ensure: + - Backtracking, overlay rendering, and session replay can handle a partially + revealed cell evolving over time. + +Given the complexity, this direction is best suited for a deeper refactor where +we’re comfortable changing the shape of the streaming API, not just its width +parameter. + +--- + +## 7. Design Direction 3: Hybrid “Visual Line Count” Model + +A more incremental (but complex) approach would try to preserve the current +`StreamController` API while making the collector width‑agnostic: + +- Track “number of visual lines committed” as a scalar `N`. +- On each render, re‑render the full source with `append_markdown(..., None)` + and wrap to the *current* width. +- Display only the first `N` visual lines; the remainder stays hidden until the + next tick. + +This keeps the idea of “we reveal one line per tick” and allows reflow, but: + +- Width changes mid‑stream will cause `N` visual lines to correspond to a + different amount of text than before (more text if the width grows, less if + it shrinks). +- You still need a place to store the raw buffer and the committed `N`, which + pushes towards a streaming cell type similar to Direction 2. + +This model is conceptually appealing (because `StreamController` still owns +“line count”), but it’s arguably more complex than Direction 1 and doesn’t buy +much over a clean streaming cell refactor. + +--- + +## 8. Recommended Path Forward + +Given the tradeoffs, a pragmatic staged approach could be: + +1. **Stage 1 – behavior fix with minimal code churn.** + - Change streaming to use `MarkdownStreamCollector::new(None)` instead of + passing a width, so streamed messages reflow based on the current + viewport width via `AgentMessageCell::display_lines(width)`. + - Accept that animation steps are per logical line instead of per + pre‑wrapped visual line. + +2. **Stage 2 – if necessary, richer streaming cell.** + - If we want finer control of animation granularity or more sophisticated + streaming behavior (e.g. per visual line with correct reflow), introduce a + dedicated `StreamingAgentMessageCell` as sketched above and move commit / + animation semantics into that cell. + +3. **Throughout: keep tests green.** + - Use existing tests in: + - `tui/src/markdown_stream.rs` + - `tui/src/markdown_render.rs` + - `tui/src/history_cell.rs` + - Add new tests that: + - Stream a long paragraph at one width, then simulate a resize and verify + the transcript reflows as expected. + - Ensure lists / blockquotes / headings maintain their semantics under + streaming with the new model. + +This doc is intentionally speculative; it describes the problem and outlines +options without committing to a concrete implementation. Any actual change +should be scoped and rolled out carefully, with a focus on keeping the +streaming UX smooth while making the layout more robust to viewport changes. + From 7bc3a11cb0893e8a7ad1cd81870b0c7f57fb35e7 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 12:32:26 -0800 Subject: [PATCH 11/20] feat: add clipboard copy for transcript selection - Introduce a clipboard_copy module backed by arboard, wrapped in a small ClipboardManager abstraction. - Implement App::copy_transcript_selection bound to Ctrl+Y, which re-renders the visible transcript into a buffer and copies the selected region as plain text. - Surface copy success/failure in the footer via a CopyStatus enum so users get inline feedback without adding history messages. --- codex-rs/tui/src/app.rs | 168 ++++++++++++++++++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 11 ++ codex-rs/tui/src/bottom_pane/footer.rs | 21 +++ codex-rs/tui/src/bottom_pane/mod.rs | 6 + codex-rs/tui/src/chatwidget.rs | 9 + codex-rs/tui/src/clipboard_copy.rs | 79 ++++++++ codex-rs/tui/src/lib.rs | 1 + 7 files changed, 295 insertions(+) create mode 100644 codex-rs/tui/src/clipboard_copy.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4fea0bb9746..a9b6cf26ef7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,6 +3,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; +use crate::clipboard_copy; use crate::custom_terminal::Frame; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; @@ -1363,6 +1364,14 @@ impl App { self.chat_widget.handle_key_event(key_event); } } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.copy_transcript_selection(tui); + } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { code: KeyCode::Enter, @@ -1456,6 +1465,165 @@ impl App { }; } + fn copy_transcript_selection(&mut self, tui: &tui::Tui) { + let (anchor, head) = match ( + self.transcript_selection.anchor, + self.transcript_selection.head, + ) { + (Some(a), Some(h)) if a != h => (a, h), + _ => return, + }; + + let size = tui.terminal.last_known_screen_size; + let width = size.width; + let height = size.height; + if width == 0 || height == 0 { + return; + } + + let chat_height = self.chat_widget.desired_height(width); + if chat_height >= height { + return; + } + + let transcript_height = height.saturating_sub(chat_height); + if transcript_height == 0 { + return; + } + + let mut buf = Buffer::empty(Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }); + + let transcript_area = Rect { + x: 0, + y: 0, + width, + height: transcript_height, + }; + + let cells = self.transcript_cells.clone(); + let (lines, _) = build_transcript_lines(&cells, transcript_area.width); + let total_lines = lines.len(); + if total_lines == 0 { + return; + } + + let max_visible = transcript_area.height as usize; + let max_start = total_lines.saturating_sub(max_visible); + let top_offset = self.transcript_view_top.min(max_start); + + Clear.render_ref(transcript_area, &mut buf); + + for (row_index, line_index) in (top_offset..total_lines).enumerate() { + if row_index >= max_visible { + break; + } + let row_area = Rect { + x: transcript_area.x, + y: transcript_area.y + row_index as u16, + width: transcript_area.width, + height: 1, + }; + lines[line_index].render_ref(row_area, &mut buf); + } + + let base_x = transcript_area.x.saturating_add(2); + let max_x = transcript_area.right().saturating_sub(1); + + let mut start = anchor; + let mut end = head; + if (end.0 < start.0) || (end.0 == start.0 && end.1 < start.1) { + std::mem::swap(&mut start, &mut end); + } + let (start_line, start_col) = start; + let (end_line, end_col) = end; + + let mut lines_out: Vec = Vec::new(); + + for row in 0..transcript_area.height { + let y = transcript_area.y + row; + let row_index = usize::from(row); + let line_index = top_offset.saturating_add(row_index); + + if line_index < start_line || line_index > end_line { + continue; + } + + let row_sel_start = if line_index == start_line { + start_col.max(base_x) + } else { + base_x + }; + let row_sel_end = if line_index == end_line { + end_col.min(max_x) + } else { + max_x + }; + + if row_sel_start > row_sel_end { + continue; + } + + let mut first_text_x = None; + let mut last_text_x = None; + for x in base_x..=max_x { + let cell = &buf[(x, y)]; + if cell.symbol() != " " { + if first_text_x.is_none() { + first_text_x = Some(x); + } + last_text_x = Some(x); + } + } + + let (text_start, text_end) = match (first_text_x, last_text_x) { + (Some(s), Some(e)) => (s, e), + _ => { + // Line has no visible text but lies within the selected range. + // Preserve it as an empty line in the copied text. + lines_out.push(String::new()); + continue; + } + }; + + let from_x = row_sel_start.max(text_start); + let to_x = row_sel_end.min(text_end); + if from_x > to_x { + continue; + } + + let mut line_text = String::new(); + for x in from_x..=to_x { + let cell = &buf[(x, y)]; + let symbol = cell.symbol(); + if !symbol.is_empty() { + line_text.push_str(symbol); + } + } + + lines_out.push(line_text); + } + + if lines_out.is_empty() { + return; + } + + let text = lines_out.join("\n"); + match clipboard_copy::copy_text(text) { + Ok(()) => { + self.chat_widget.mark_copy_success(); + } + Err(err) => { + tracing::error!(error = %err, "failed to copy selection to clipboard"); + self.chat_widget.mark_copy_failure(); + } + } + } + #[cfg(target_os = "windows")] fn spawn_world_writable_scan( cwd: PathBuf, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4c435fe67a6..4d4e478ecd3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -20,6 +20,7 @@ use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandItem; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; +use super::footer::CopyStatus; use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; @@ -116,6 +117,7 @@ pub(crate) struct ChatComposer { transcript_scrolled: bool, transcript_selection_active: bool, transcript_scroll_position: Option<(usize, usize)>, + copy_status: Option, } /// Popup state – at most one can be visible at any time. @@ -162,6 +164,7 @@ impl ChatComposer { transcript_scrolled: false, transcript_selection_active: false, transcript_scroll_position: None, + copy_status: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); @@ -1396,6 +1399,7 @@ impl ChatComposer { transcript_scrolled: self.transcript_scrolled, transcript_selection_active: self.transcript_selection_active, transcript_scroll_position: self.transcript_scroll_position, + copy_status: self.copy_status, } } @@ -1535,6 +1539,13 @@ impl ChatComposer { self.transcript_scrolled = scrolled; self.transcript_selection_active = selection_active; self.transcript_scroll_position = scroll_position; + if !selection_active { + self.copy_status = None; + } + } + + pub(crate) fn set_copy_status(&mut self, status: Option) { + self.copy_status = status; } pub(crate) fn set_context_window_percent(&mut self, percent: Option) { diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index c61fe05bb99..8195ed9c3fa 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -11,6 +11,12 @@ use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +#[derive(Clone, Copy, Debug)] +pub(crate) enum CopyStatus { + Copied, + Failed, +} + #[derive(Clone, Copy, Debug)] pub(crate) struct FooterProps { pub(crate) mode: FooterMode, @@ -21,6 +27,7 @@ pub(crate) struct FooterProps { pub(crate) transcript_scrolled: bool, pub(crate) transcript_selection_active: bool, pub(crate) transcript_scroll_position: Option<(usize, usize)>, + pub(crate) copy_status: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -110,6 +117,13 @@ fn footer_lines(props: FooterProps) -> Vec> { line.push_span(key_hint::ctrl(KeyCode::Char('y'))); line.push_span(" copy selection".dim()); } + if let Some(status) = props.copy_status { + line.push_span(" · ".dim()); + match status { + CopyStatus::Copied => line.push_span("selection copied".dim()), + CopyStatus::Failed => line.push_span("copy failed".dim()), + } + } vec![line] } FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { @@ -426,6 +440,7 @@ mod tests { transcript_scrolled: false, transcript_selection_active: false, transcript_scroll_position: None, + copy_status: None, }, ); @@ -440,6 +455,7 @@ mod tests { transcript_scrolled: false, transcript_selection_active: false, transcript_scroll_position: None, + copy_status: None, }, ); @@ -454,6 +470,7 @@ mod tests { transcript_scrolled: false, transcript_selection_active: false, transcript_scroll_position: None, + copy_status: None, }, ); @@ -468,6 +485,7 @@ mod tests { transcript_scrolled: false, transcript_selection_active: false, transcript_scroll_position: None, + copy_status: None, }, ); @@ -482,6 +500,7 @@ mod tests { transcript_scrolled: false, transcript_selection_active: false, transcript_scroll_position: None, + copy_status: None, }, ); @@ -496,6 +515,7 @@ mod tests { transcript_scrolled: false, transcript_selection_active: false, transcript_scroll_position: None, + copy_status: None, }, ); @@ -510,6 +530,7 @@ mod tests { transcript_scrolled: false, transcript_selection_active: false, transcript_scroll_position: None, + copy_status: None, }, ); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d472d4a598b..fe6e2af35aa 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -48,6 +48,7 @@ pub(crate) enum CancellationEvent { pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::InputResult; use codex_protocol::custom_prompts::CustomPrompt; +pub(crate) use footer::CopyStatus; use crate::status_indicator_widget::StatusIndicatorWidget; pub(crate) use list_selection_view::SelectionAction; @@ -377,6 +378,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_copy_status(&mut self, status: Option) { + self.composer.set_copy_status(status); + self.request_redraw(); + } + /// Update custom prompts available for the slash popup. pub(crate) fn set_custom_prompts(&mut self, prompts: Vec) { self.composer.set_custom_prompts(prompts); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index d1310a8beb4..cd4a2214678 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -79,6 +79,7 @@ use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; +use crate::bottom_pane::CopyStatus; use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -365,6 +366,14 @@ impl ChatWidget { self.bottom_pane.update_status_header(header); } + pub(crate) fn mark_copy_success(&mut self) { + self.bottom_pane.set_copy_status(Some(CopyStatus::Copied)); + } + + pub(crate) fn mark_copy_failure(&mut self) { + self.bottom_pane.set_copy_status(Some(CopyStatus::Failed)); + } + // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs new file mode 100644 index 00000000000..76718704e5f --- /dev/null +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -0,0 +1,79 @@ +use tracing::error; + +#[derive(Debug)] +pub enum ClipboardError { + ClipboardUnavailable(String), + WriteFailed(String), +} + +impl std::fmt::Display for ClipboardError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClipboardError::ClipboardUnavailable(msg) => { + write!(f, "clipboard unavailable: {msg}") + } + ClipboardError::WriteFailed(msg) => write!(f, "failed to write to clipboard: {msg}"), + } + } +} + +impl std::error::Error for ClipboardError {} + +pub trait ClipboardManager { + fn set_text(&mut self, text: String) -> Result<(), ClipboardError>; +} + +#[cfg(not(target_os = "android"))] +pub struct ArboardClipboardManager { + inner: Option, +} + +#[cfg(not(target_os = "android"))] +impl ArboardClipboardManager { + pub fn new() -> Self { + match arboard::Clipboard::new() { + Ok(cb) => Self { inner: Some(cb) }, + Err(err) => { + error!(error = %err, "failed to initialize clipboard"); + Self { inner: None } + } + } + } +} + +#[cfg(not(target_os = "android"))] +impl ClipboardManager for ArboardClipboardManager { + fn set_text(&mut self, text: String) -> Result<(), ClipboardError> { + let Some(cb) = &mut self.inner else { + return Err(ClipboardError::ClipboardUnavailable( + "clipboard is not available in this environment".to_string(), + )); + }; + cb.set_text(text) + .map_err(|e| ClipboardError::WriteFailed(e.to_string())) + } +} + +#[cfg(target_os = "android")] +pub struct ArboardClipboardManager; + +#[cfg(target_os = "android")] +impl ArboardClipboardManager { + pub fn new() -> Self { + ArboardClipboardManager + } +} + +#[cfg(target_os = "android")] +impl ClipboardManager for ArboardClipboardManager { + fn set_text(&mut self, _text: String) -> Result<(), ClipboardError> { + Err(ClipboardError::ClipboardUnavailable( + "clipboard text copy is unsupported on Android".to_string(), + )) + } +} + +pub fn copy_text(text: String) -> Result<(), ClipboardError> { + let mut manager = ArboardClipboardManager::new(); + manager.set_text(text) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2854176982a..c25a306e00c 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -41,6 +41,7 @@ mod ascii_animation; mod bottom_pane; mod chatwidget; mod cli; +mod clipboard_copy; mod clipboard_paste; mod color; pub mod custom_terminal; From 892a8c86b0405ce7023f605461bcd2f6006cdf7a Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 13:29:47 -0800 Subject: [PATCH 12/20] feat: print session transcript after TUI exit - Extend AppExitInfo with a session_lines field containing the flattened transcript lines. - Collect transcript_lines at the end of App::run and return them to the caller before clearing the TUI. - Update both codex and codex-tui entrypoints to print the session transcript after leaving the alternate screen so it ends up in scrollback. --- codex-rs/cli/src/main.rs | 5 +++++ codex-rs/tui/src/app.rs | 19 +++++++++++++++++++ codex-rs/tui/src/lib.rs | 4 ++++ codex-rs/tui/src/main.rs | 3 +++ 4 files changed, 31 insertions(+) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2b066197af3..64265d4665a 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -301,6 +301,9 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec anyhow::Result<()> { let update_action = exit_info.update_action; let color_enabled = supports_color::on(Stream::Stdout).is_some(); + for line in exit_info.session_lines.iter() { + println!("{line}"); + } for line in format_exit_messages(exit_info, color_enabled) { println!("{line}"); } @@ -764,6 +767,7 @@ mod tests { .map(ConversationId::from_string) .map(Result::unwrap), update_action: None, + session_lines: Vec::new(), } } @@ -773,6 +777,7 @@ mod tests { token_usage: TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }; let lines = format_exit_messages(exit_info, false); assert!(lines.is_empty()); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a9b6cf26ef7..aed9441365b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -75,6 +75,7 @@ pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, pub update_action: Option, + pub session_lines: Vec, } fn session_summary( @@ -193,6 +194,7 @@ async fn handle_model_migration_prompt_if_needed( token_usage: TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } } @@ -426,11 +428,28 @@ impl App { app.handle_tui_event(tui, event).await? } } {} + let width = tui.terminal.last_known_screen_size.width; + let session_lines = if width == 0 { + Vec::new() + } else { + let (lines, _) = build_transcript_lines(&app.transcript_cells, width); + lines + .into_iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() + }; + tui.terminal.clear()?; Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), update_action: app.pending_update_action, + session_lines, }) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c25a306e00c..3b8c0be4936 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -374,6 +374,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: Some(action), + session_lines: Vec::new(), }); } } @@ -413,6 +414,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly @@ -448,6 +450,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } } @@ -486,6 +489,7 @@ async fn run_ratatui_app( token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, update_action: None, + session_lines: Vec::new(), }); } other => other, diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 50ea95f170f..a03b81014e6 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -22,6 +22,9 @@ fn main() -> anyhow::Result<()> { .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); let exit_info = run_main(inner, codex_linux_sandbox_exe).await?; + for line in exit_info.session_lines.iter() { + println!("{line}"); + } let token_usage = exit_info.token_usage; if !token_usage.is_zero() { println!("{}", codex_core::protocol::FinalOutput::from(token_usage),); From b5138e63fcc832e1516c471975727e2f57071656 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 13:37:52 -0800 Subject: [PATCH 13/20] feat: style exit transcript with ANSI - Reuse insert_history::write_spans to render transcript lines into ANSI, preserving markdown and user-message styling when printing on exit. - Introduce a render_lines_to_ansi helper that merges line and span styles before emitting the vt100 sequence. - Print a blank line between the rendered transcript and the token usage summary for better separation in scrollback. --- codex-rs/cli/src/main.rs | 3 +++ codex-rs/tui/src/app.rs | 29 ++++++++++++++++++++--------- codex-rs/tui/src/insert_history.rs | 2 +- codex-rs/tui/src/main.rs | 3 +++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 64265d4665a..1cc3293f585 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -304,6 +304,9 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { for line in exit_info.session_lines.iter() { println!("{line}"); } + if !exit_info.session_lines.is_empty() { + println!(); + } for line in format_exit_messages(exit_info, color_enabled) { println!("{line}"); } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index aed9441365b..83b1759e5ca 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -433,15 +433,7 @@ impl App { Vec::new() } else { let (lines, _) = build_transcript_lines(&app.transcript_cells, width); - lines - .into_iter() - .map(|line| { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect::() - }) - .collect() + render_lines_to_ansi(&lines) }; tui.terminal.clear()?; @@ -1726,6 +1718,25 @@ fn build_transcript_lines( (lines, meta) } +fn render_lines_to_ansi(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|line| { + let merged_spans: Vec> = line + .spans + .iter() + .map(|s| ratatui::text::Span { + style: s.style.patch(line.style), + content: s.content.clone(), + }) + .collect(); + let mut buf: Vec = Vec::new(); + let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter()); + String::from_utf8(buf).unwrap_or_default() + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 36ef47da5e4..e0e2731678a 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -241,7 +241,7 @@ impl ModifierDiff { } } -fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> +pub(crate) fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> where I: IntoIterator>, { diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index a03b81014e6..ae7dbaa137d 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -25,6 +25,9 @@ fn main() -> anyhow::Result<()> { for line in exit_info.session_lines.iter() { println!("{line}"); } + if !exit_info.session_lines.is_empty() { + println!(); + } let token_usage = exit_info.token_usage; if !token_usage.is_zero() { println!("{}", codex_core::protocol::FinalOutput::from(token_usage),); From 2cef77eae4aea68fcf6b08c262dd3b3d58ad2326 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 13:48:49 -0800 Subject: [PATCH 14/20] fix: pad user prompts in exit transcript --- codex-rs/tui/src/app.rs | 44 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 83b1759e5ca..f9fce773450 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -432,8 +432,13 @@ impl App { let session_lines = if width == 0 { Vec::new() } else { - let (lines, _) = build_transcript_lines(&app.transcript_cells, width); - render_lines_to_ansi(&lines) + let (lines, meta) = build_transcript_lines(&app.transcript_cells, width); + let is_user_cell: Vec = app + .transcript_cells + .iter() + .map(|c| c.as_any().is::()) + .collect(); + render_lines_to_ansi(&lines, &meta, &is_user_cell, width) }; tui.terminal.clear()?; @@ -1718,11 +1723,23 @@ fn build_transcript_lines( (lines, meta) } -fn render_lines_to_ansi(lines: &[Line<'static>]) -> Vec { +fn render_lines_to_ansi( + lines: &[Line<'static>], + meta: &[Option<(usize, usize)>], + is_user_cell: &[bool], + width: u16, +) -> Vec { lines .iter() - .map(|line| { - let merged_spans: Vec> = line + .enumerate() + .map(|(idx, line)| { + let is_user_row = meta + .get(idx) + .and_then(|entry| entry.as_ref()) + .map(|(cell_index, _)| is_user_cell.get(*cell_index).copied().unwrap_or(false)) + .unwrap_or(false); + + let mut merged_spans: Vec> = line .spans .iter() .map(|s| ratatui::text::Span { @@ -1730,6 +1747,23 @@ fn render_lines_to_ansi(lines: &[Line<'static>]) -> Vec { content: s.content.clone(), }) .collect(); + + if is_user_row && width > 0 { + let text: String = merged_spans.iter().map(|s| s.content.as_ref()).collect(); + let text_width = unicode_width::UnicodeWidthStr::width(text.as_str()); + let total_width = usize::from(width); + if text_width < total_width { + let pad_len = total_width.saturating_sub(text_width); + if pad_len > 0 { + let pad_style = crate::style::user_message_style(); + merged_spans.push(ratatui::text::Span { + style: pad_style, + content: " ".repeat(pad_len).into(), + }); + } + } + } + let mut buf: Vec = Vec::new(); let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter()); String::from_utf8(buf).unwrap_or_default() From 87fd5fd59e0caf3b24af78456f26a36cfbdebabd Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Tue, 2 Dec 2025 16:02:21 -0800 Subject: [PATCH 15/20] fix: redraw TUI after standby --- codex-rs/tui/src/tui/job_control.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs index 3680419489f..26b711673e0 100644 --- a/codex-rs/tui/src/tui/job_control.rs +++ b/codex-rs/tui/src/tui/job_control.rs @@ -157,6 +157,7 @@ impl PreparedResumeAction { match self { PreparedResumeAction::RealignViewport(area) => { terminal.set_viewport_area(area); + terminal.clear()?; } PreparedResumeAction::RestoreAltScreen => { execute!(terminal.backend_mut(), EnterAlternateScreen)?; From b0021eae3fa8eb90bf908825bfd64e71f55252a3 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Wed, 3 Dec 2025 10:22:29 -0800 Subject: [PATCH 16/20] fix: clear screen after suspend/overlay --- codex-rs/tui/src/app.rs | 187 +++++++++++------- codex-rs/tui/src/app_backtrack.rs | 5 + codex-rs/tui/src/model_migration.rs | 4 + .../tui/src/onboarding/onboarding_screen.rs | 4 + codex-rs/tui/src/tui.rs | 31 ++- codex-rs/tui/src/tui/job_control.rs | 46 ++++- 6 files changed, 188 insertions(+), 89 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index f9fce773450..a87c867b026 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -218,6 +218,7 @@ pub(crate) struct App { pub(crate) transcript_cells: Vec>, transcript_scroll: TranscriptScroll, transcript_selection: TranscriptSelection, + printed_history_cells: usize, transcript_view_top: usize, transcript_total_lines: usize, @@ -366,6 +367,7 @@ impl App { transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::ToBottom, transcript_selection: TranscriptSelection::default(), + printed_history_cells: 0, transcript_view_top: 0, transcript_total_lines: 0, overlay: None, @@ -432,13 +434,13 @@ impl App { let session_lines = if width == 0 { Vec::new() } else { - let (lines, meta) = build_transcript_lines(&app.transcript_cells, width); - let is_user_cell: Vec = app - .transcript_cells - .iter() - .map(|c| c.as_any().is::()) - .collect(); - render_lines_to_ansi(&lines, &meta, &is_user_cell, width) + let start = app.printed_history_cells.min(app.transcript_cells.len()); + let cells = &app.transcript_cells[start..]; + if cells.is_empty() { + Vec::new() + } else { + render_cells_to_ansi(cells, width) + } }; tui.terminal.clear()?; @@ -450,6 +452,32 @@ impl App { }) } + pub(crate) fn handle_suspend(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.prepare_suspend_history(tui)?; + tui.suspend()?; + tui.frame_requester().schedule_frame(); + Ok(()) + } + + fn prepare_suspend_history(&mut self, tui: &mut tui::Tui) -> Result<()> { + let width = tui.terminal.last_known_screen_size.width; + if width == 0 { + return Ok(()); + } + let start = self.printed_history_cells.min(self.transcript_cells.len()); + let cells = &self.transcript_cells[start..]; + if cells.is_empty() { + return Ok(()); + } + let new_lines = render_cells_to_ansi(cells, width); + if new_lines.is_empty() { + return Ok(()); + } + self.printed_history_cells = self.transcript_cells.len(); + tui.set_suspend_history_lines(new_lines); + Ok(()) + } + pub(crate) async fn handle_tui_event( &mut self, tui: &mut tui::Tui, @@ -457,73 +485,78 @@ impl App { ) -> Result { if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; - } else { - match event { - TuiEvent::Key(key_event) => { - self.handle_key_event(tui, key_event).await; - } - TuiEvent::Mouse(mouse_event) => { - self.handle_mouse_event(tui, mouse_event); - } - TuiEvent::Paste(pasted) => { - // Many terminals convert newlines to \r when pasting (e.g., iTerm2), - // but tui-textarea expects \n. Normalize CR to LF. - // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 - // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 - let pasted = pasted.replace("\r", "\n"); - self.chat_widget.handle_paste(pasted); - } - TuiEvent::Draw => { - self.chat_widget.maybe_post_pending_notification(tui); - if self - .chat_widget - .handle_paste_burst_tick(tui.frame_requester()) - { - return Ok(true); - } - let cells = self.transcript_cells.clone(); - tui.draw( - //self.chat_widget.desired_height(tui.terminal.size()?.width), - tui.terminal.size()?.height, - |frame| { - let chat_height = self.chat_widget.desired_height(frame.area().width); - // peg chat to the bottom - let chat_area = Rect { - x: frame.area().x, - y: frame.area().bottom().saturating_sub(chat_height), - width: frame.area().width, - height: chat_height, - }; - self.chat_widget.render(chat_area, frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { - frame.set_cursor_position((x, y)); - } - self.render_transcript_cells(frame, &cells); + return Ok(true); + } - let transcript_scrolled = - !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); - let selection_active = matches!( - (self.transcript_selection.anchor, self.transcript_selection.head), - (Some(a), Some(b)) if a != b - ); - let scroll_position = if self.transcript_total_lines == 0 { - None - } else { - Some(( - self.transcript_view_top.saturating_add(1), - self.transcript_total_lines, - )) - }; - self.chat_widget.set_transcript_ui_state( - transcript_scrolled, - selection_active, - scroll_position, - ); - }, - )?; + match event { + TuiEvent::Suspend => { + self.handle_suspend(tui)?; + } + TuiEvent::Key(key_event) => { + self.handle_key_event(tui, key_event).await; + } + TuiEvent::Mouse(mouse_event) => { + self.handle_mouse_event(tui, mouse_event); + } + TuiEvent::Paste(pasted) => { + // Many terminals convert newlines to \r when pasting (e.g., iTerm2), + // but tui-textarea expects \n. Normalize CR to LF. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); + self.chat_widget.handle_paste(pasted); + } + TuiEvent::Draw => { + self.chat_widget.maybe_post_pending_notification(tui); + if self + .chat_widget + .handle_paste_burst_tick(tui.frame_requester()) + { + return Ok(true); } + let cells = self.transcript_cells.clone(); + tui.draw( + //self.chat_widget.desired_height(tui.terminal.size()?.width), + tui.terminal.size()?.height, + |frame| { + let chat_height = self.chat_widget.desired_height(frame.area().width); + // peg chat to the bottom + let chat_area = Rect { + x: frame.area().x, + y: frame.area().bottom().saturating_sub(chat_height), + width: frame.area().width, + height: chat_height, + }; + self.chat_widget.render(chat_area, frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { + frame.set_cursor_position((x, y)); + } + self.render_transcript_cells(frame, &cells); + + let transcript_scrolled = + !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); + let selection_active = matches!( + (self.transcript_selection.anchor, self.transcript_selection.head), + (Some(a), Some(b)) if a != b + ); + let scroll_position = if self.transcript_total_lines == 0 { + None + } else { + Some(( + self.transcript_view_top.saturating_add(1), + self.transcript_total_lines, + )) + }; + self.chat_widget.set_transcript_ui_state( + transcript_scrolled, + selection_active, + scroll_position, + ); + }, + )?; } } + Ok(true) } @@ -1723,6 +1756,18 @@ fn build_transcript_lines( (lines, meta) } +fn render_cells_to_ansi(cells: &[Arc], width: u16) -> Vec { + let (lines, meta) = build_transcript_lines(cells, width); + if lines.is_empty() { + return Vec::new(); + } + let is_user_cell: Vec = cells + .iter() + .map(|c| c.as_any().is::()) + .collect(); + render_lines_to_ansi(&lines, &meta, &is_user_cell, width) +} + fn render_lines_to_ansi( lines: &[Line<'static>], meta: &[Option<(usize, usize)>], @@ -1817,6 +1862,7 @@ mod tests { transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::ToBottom, transcript_selection: TranscriptSelection::default(), + printed_history_cells: 0, transcript_view_top: 0, transcript_total_lines: 0, overlay: None, @@ -1858,6 +1904,7 @@ mod tests { transcript_cells: Vec::new(), transcript_scroll: TranscriptScroll::ToBottom, transcript_selection: TranscriptSelection::default(), + printed_history_cells: 0, transcript_view_top: 0, transcript_total_lines: 0, overlay: None, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b161867e445..a0f17b3110d 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -40,6 +40,10 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { + if matches!(event, TuiEvent::Suspend) { + self.handle_suspend(tui)?; + return Ok(true); + } if self.backtrack.overlay_preview_active { match event { TuiEvent::Key(KeyEvent { @@ -125,6 +129,7 @@ impl App { /// Close transcript overlay and restore normal UI. pub(crate) fn close_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.leave_alt_screen(); + let _ = tui.terminal.clear(); let was_backtrack = self.backtrack.overlay_preview_active; if !self.deferred_history_lines.is_empty() { let lines = std::mem::take(&mut self.deferred_history_lines); diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 0e3ec5aab63..828e45286d4 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -103,6 +103,10 @@ pub(crate) async fn run_model_migration_prompt( match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), TuiEvent::Paste(_) => {} + TuiEvent::Suspend => { + let _ = alt.tui.suspend(); + alt.tui.frame_requester().schedule_frame(); + } TuiEvent::Draw => { let _ = alt.tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 51101c74743..f7aa80cd163 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -385,6 +385,10 @@ pub(crate) async fn run_onboarding_app( TuiEvent::Paste(text) => { onboarding_screen.handle_paste(text); } + TuiEvent::Suspend => { + let _ = tui.suspend(); + tui.frame_requester().schedule_frame(); + } TuiEvent::Draw => { if !did_full_clear_after_success && onboarding_screen.steps.iter().any(|step| { diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 1c0892e6862..da830ea0c9d 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -167,6 +167,7 @@ pub enum TuiEvent { Paste(String), Draw, Mouse(crossterm::event::MouseEvent), + Suspend, } pub struct Tui { @@ -235,12 +236,6 @@ impl Tui { let mut crossterm_events = crossterm::event::EventStream::new(); let mut draw_rx = self.draw_tx.subscribe(); - // State for tracking how we should resume from ^Z suspend. - #[cfg(unix)] - let suspend_context = self.suspend_context.clone(); - #[cfg(unix)] - let alt_screen_active = self.alt_screen_active.clone(); - let terminal_focused = self.terminal_focused.clone(); let event_stream = async_stream::stream! { loop { @@ -250,9 +245,7 @@ impl Tui { Event::Key(key_event) => { #[cfg(unix)] if SUSPEND_KEY.is_press(key_event) { - let _ = suspend_context.suspend(&alt_screen_active); - // We continue here after resume. - yield TuiEvent::Draw; + yield TuiEvent::Suspend; continue; } yield TuiEvent::Key(key_event); @@ -296,6 +289,26 @@ impl Tui { Box::pin(event_stream) } + pub fn set_suspend_history_lines(&mut self, lines: Vec) { + #[cfg(unix)] + { + self.suspend_context.set_suspend_history_lines(lines); + } + #[cfg(not(unix))] + let _ = lines; + } + + pub fn suspend(&mut self) -> Result<()> { + #[cfg(unix)] + { + return self.suspend_context.suspend(&self.alt_screen_active); + } + #[cfg(not(unix))] + { + Ok(()) + } + } + /// Enter alternate screen and expand the viewport to full terminal size, saving the current /// inline viewport for restoration when leaving. pub fn enter_alt_screen(&mut self) -> Result<()> { diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs index 26b711673e0..fd4850a2372 100644 --- a/codex-rs/tui/src/tui/job_control.rs +++ b/codex-rs/tui/src/tui/job_control.rs @@ -45,6 +45,8 @@ pub struct SuspendContext { resume_pending: Arc>>, /// Inline viewport cursor row used to place the cursor before yielding during suspend. suspend_cursor_y: Arc, + /// Session lines to print to stdout when suspending, if any. + history_lines: Arc>>, } impl SuspendContext { @@ -52,6 +54,7 @@ impl SuspendContext { Self { resume_pending: Arc::new(Mutex::new(None)), suspend_cursor_y: Arc::new(AtomicU16::new(0)), + history_lines: Arc::new(Mutex::new(Vec::new())), } } @@ -72,7 +75,22 @@ impl SuspendContext { } let y = self.suspend_cursor_y.load(Ordering::Relaxed); let _ = execute!(stdout(), MoveTo(0, y), Show); - suspend_process() + super::restore()?; + + let lines = self.take_suspend_history_lines(); + if !lines.is_empty() { + use std::io::Write as _; + let mut out = stdout(); + for line in lines { + let _ = writeln!(out, "{line}"); + } + let _ = out.flush(); + } + + unsafe { libc::kill(0, libc::SIGTSTP) }; + // After the process resumes, reapply terminal modes so drawing can continue. + super::set_modes()?; + Ok(()) } /// Consume the pending resume intent and precompute any viewport changes needed post-resume. @@ -112,6 +130,14 @@ impl SuspendContext { self.suspend_cursor_y.store(value, Ordering::Relaxed); } + /// Set the session lines that should be printed when suspending. + pub(crate) fn set_suspend_history_lines(&self, lines: Vec) { + *self + .history_lines + .lock() + .unwrap_or_else(PoisonError::into_inner) = lines; + } + /// Record a pending resume action to apply after SIGTSTP returns control. fn set_resume_action(&self, value: ResumeAction) { *self @@ -127,6 +153,15 @@ impl SuspendContext { .unwrap_or_else(PoisonError::into_inner) .take() } + + /// Take and clear any pending history lines to print on suspend. + fn take_suspend_history_lines(&self) -> Vec { + self.history_lines + .lock() + .unwrap_or_else(PoisonError::into_inner) + .drain(..) + .collect() + } } /// Captures what should happen when returning from suspend. @@ -172,12 +207,3 @@ impl PreparedResumeAction { Ok(()) } } - -/// Deliver SIGTSTP after restoring terminal state, then re-applies terminal modes once resumed. -fn suspend_process() -> Result<()> { - super::restore()?; - unsafe { libc::kill(0, libc::SIGTSTP) }; - // After the process resumes, reapply terminal modes so drawing can continue. - super::set_modes()?; - Ok(()) -} From 099b42e36206587d2c42f205d5475e083d84fe43 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Wed, 3 Dec 2025 11:28:38 -0800 Subject: [PATCH 17/20] docs: document TUI viewport and history model --- docs/tui_viewport_and_history.md | 466 +++++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 docs/tui_viewport_and_history.md diff --git a/docs/tui_viewport_and_history.md b/docs/tui_viewport_and_history.md new file mode 100644 index 00000000000..5786a91774d --- /dev/null +++ b/docs/tui_viewport_and_history.md @@ -0,0 +1,466 @@ +# TUI Viewport, Transcript, and History – Design Notes + +This document describes the current design of the Codex TUI viewport and history model, and explains +why we moved away from directly writing history into terminal scrollback. + +The target audience is Codex developers and curious contributors who want to understand or critique +how the TUI owns its viewport, scrollback, and suspend behavior. + +--- + +## 1. Problem Overview + +Historically, the TUI tried to “cooperate” with the terminal’s own scrollback: + +- The inline viewport sat somewhere above the bottom of the screen. +- When new history arrived, we tried to insert it directly into the terminal scrollback above the + viewport. +- On certain transitions (e.g. switching sessions, overlays), we cleared and re‑wrote portions of + the screen from scratch. + +This had several failure modes: + +- **Terminal‑dependent behavior.** + - Different terminals handle scroll regions, clears, and resize semantics differently. + - What looked correct in one terminal could drop or duplicate content in another. + +- **Resizes and layout churn.** + - The TUI reacts to resizes, focus changes, and overlay transitions. + - When the viewport moved or its size changed, our attempts to keep scrollback “aligned” with the + in‑memory history could go out of sync. + - In practice this meant: + - Some lines were lost or overwritten. + - Others were duplicated or appeared in unexpected places. + +- **“Clear and rewrite everything” didn’t save us.** + - We briefly tried a strategy of clearing large regions (or the full screen) and re‑rendering + history when the layout changed. + - This ran into two issues: + - Terminals treat full clears differently. For example, Terminal.app often leaves the cleared + screen as a “page” at the top of scrollback, some terminals interpret only a subset of the + ANSI clear/scrollback codes, and others (like iTerm2) gate “clear full scrollback” behind + explicit user consent. + - Replaying a long session is expensive and still subject to timing/race conditions with user + output (e.g. shell prompts) when we weren’t in alt screen. + +The net result: the TUI could not reliably guarantee “the history you see on screen is complete, in +order, and appears exactly once” across terminals, resizes, suspend/resume, and overlay transitions. + +--- + +## 2. Goals + +The redesign is guided by a few explicit goals: + +1. **Codex, not the terminal, owns the viewport.** + - The in‑memory transcript (a list of history entries) is the single source of truth for what’s + on screen. + - The TUI decides how to map that transcript into the current viewport; scrollback becomes an + output target, not an extra data structure we try to maintain. + +2. **History must be correct, ordered, and never silently dropped.** + - Every logical history cell should either: + - Be visible in the TUI, or + - Have been printed into scrollback as part of a suspend/exit flow. + - We would rather (rarely) duplicate content than risk losing it. + +3. **Avoid unnecessary duplication.** + - When emitting history to scrollback (on suspend or exit), print each logical cell’s content at + most once. + - Streaming cells are allowed to be “re‑seen” as they grow, but finished cells should not keep + reappearing. + +4. **Behave sensibly under resizes.** + - TUI rendering should reflow to the current width on every frame. + - History printed to scrollback may have been wrapped at different widths over time; that is + acceptable, but it must not cause missing content or unbounded duplication. + +5. **Suspend/alt‑screen interaction is predictable.** + - `Ctrl+Z` should: + - Cleanly exit alt screen, if active. + - Print a consistent transcript prefix into normal scrollback. + - Resume with the TUI fully redrawn, without stale artifacts. + +--- + +## 3. New Viewport & Transcript Model + +### 3.1 Transcript as a logical sequence of cells + +At a high level, the TUI transcript is a list of “cells”, each representing one logical thing in +the conversation: + +- A user prompt (with padding and a distinct background). +- An agent response (which may arrive in multiple streaming chunks). +- System or info rows (session headers, migration banners, reasoning summaries, etc.). + +Each cell knows how to draw itself for a given width: how many lines it needs, what prefixes to +use, how to style its content. The transcript itself is purely logical: + +- It has no scrollback coordinates or terminal state baked into it. +- It can be re‑rendered for any viewport width. + +The TUI’s job is to take this logical sequence and decide how much of it fits into the current +viewport, and how it should be wrapped and styled on screen. + +### 3.2 Building viewport lines from the transcript + +To render the main transcript area above the composer, the TUI: + +1. Defines a “transcript region” as the full frame minus the height of the bottom input area. +2. Flattens all cells into a list of visual lines, remembering for each visual line which cell it + came from and which line within that cell it corresponds to. +3. Uses this flattened list plus a scroll position to decide which visual line should appear at the + top of the region. +4. Clears the transcript region and draws the visible slice of lines into it. +5. For user messages, paints the entire row background (including padding lines) so the user block + stands out even when it does not fill the whole width. +6. Applies selection styling and other overlays on top of the rendered lines. + +Scrolling (mouse wheel, PgUp/PgDn, Home/End) operates entirely in terms of these flattened lines +and the current scroll anchor. The terminal’s own scrollback is not part of this calculation; it +only ever sees fully rendered frames. + +### 3.3 Alternate screen, overlays, and redraw guarantees + +The TUI uses the terminal’s alternate screen for: + +- The main interactive chat session (so the viewport can cover the full terminal). +- Full‑screen overlays such as the transcript pager, diff view, model migration screen, and + onboarding. + +Conceptually: + +- Entering alt screen: + - Switches the terminal into alt screen and expands the viewport to cover the full terminal. + - Clears that alt‑screen buffer. + +- Leaving alt screen: + - Disables “alternate scroll” so mouse wheel events behave predictably. + - Returns to the normal screen. + +- On leaving overlays and on resuming from suspend, the TUI viewport is explicitly cleared and fully + redrawn: + - This prevents stale overlay content or shell output from lingering in the TUI area. + - The next frame reconstructs the UI entirely from the in‑memory transcript and other state, not + from whatever the terminal happened to remember. + +Alt screen is therefore treated as a temporary render target. The only authoritative copy of the UI +is the in‑memory state. + +--- + +## 4. Mouse, Selection, and Scrolling + +Mouse interaction is a first‑class part of the new design: + +- **Scrolling.** + - Mouse wheel scrolls the transcript in fixed line increments. + - Keyboard shortcuts (PgUp/PgDn/Home/End) use the same scroll model, so the footer can show + consistent hints regardless of input device. + +- **Selection.** + - A click‑and‑drag gesture defines a linear text selection in terms of the flattened transcript + lines (not raw buffer coordinates). + - Selection tracks the *content* rather than a fixed screen row. When the transcript scrolls, the + selection moves along with the underlying lines instead of staying glued to a particular Y + position. + - The selection only covers the “transcript text” area; it intentionally skips the left gutter + that we use for bullets/prefixes. + +- **Copy.** + - When the user triggers copy, the TUI re‑renders just the transcript region off‑screen using the + same wrapping as the visible view. + - It then walks the selected lines and columns in that off‑screen buffer to reconstruct the exact + text region the user highlighted (including internal spaces and empty lines). + - That text is sent to the system clipboard and a status footer indicates success or failure. + +Because scrolling, selection, and copy all operate on the same flattened transcript representation, +they remain consistent even as the viewport resizes or the chat composer grows/shrinks. Owning our +own scrolling also means we must own mouse interactions end‑to‑end: if we left scrolling entirely +to the terminal, we could not reliably line up selections with transcript content or avoid +accidentally copying gutter/margin characters instead of just the conversation text. + +--- + +## 5. Printing History to Scrollback + +We still want the final session (and suspend points) to appear in the user’s normal scrollback, but +we no longer try to maintain scrollback in lock‑step with the TUI frame. Instead, we treat +scrollback as an **append‑only log** of logical transcript cells. + +In practice this means: + +- The TUI may print history both when you suspend (`Ctrl+Z`) and when you exit. +- Some users may prefer to only print on exit (for example to keep scrollback quieter during long + sessions). The current design anticipates gating suspend‑time printing behind a config toggle so + that this behavior can be made opt‑in or opt‑out without touching the core viewport logic, but + that switch has not been implemented yet. + +### 5.1 Cell‑based high‑water mark + +Internally, the TUI keeps a simple “high‑water mark” for history printing: + +- Think of this as “how many cells at the front of the transcript have already been sent to + scrollback.” +- It is just a counter over the logical transcript, not over wrapped lines. +- It moves forward only when we have actually printed more history. + +This means we never try to guess “how many terminal lines have already been printed”; we only +remember that “the first N logical entries are done.” + +### 5.2 Rendering new cells for scrollback + +When we need to print history (on suspend or exit), we: + +1. Take the suffix of the transcript that lies beyond the high‑water mark. +2. Render just that suffix into styled lines at the **current** terminal width. +3. Write those lines to stdout. +4. Advance the high‑water mark to include all cells we just printed. + +Older cells are never re‑rendered for scrollback; they remain in whatever wrapping they had when +they were first printed. This avoids the line‑count–based bugs we had before while still allowing +the on‑screen TUI to reflow freely. + +### 5.3 Suspend (`Ctrl+Z`) flow + +On suspend (typically `Ctrl+Z` on Unix): + +- Before yielding control back to the shell, the TUI: + - Leaves alt screen if it is active and restores normal terminal modes. + - Determines which transcript cells have not yet been printed and renders them for the current + width. + - Prints those new lines once into normal scrollback. + - Marks those cells as printed in the high‑water mark. + - Finally, sends the process to the background. + +On `fg`, the process resumes, re‑enters TUI modes, and redraws the viewport from the in‑memory +transcript. The history printed during suspend stays in scrollback and is not touched again. + +### 5.4 Exit flow + +When the TUI exits, we follow the same principle: + +- We compute the suffix of the transcript that has not yet been printed (taking into account any + prior suspends). +- We render just that suffix to styled lines at the current width. +- The outer `main` function leaves alt screen, restores the terminal, and prints those lines, plus a + blank line and token usage summary. + +If you never suspended, exit prints the entire transcript once. If you did suspend one or more +times, exit prints only the cells appended after the last suspend. In both cases, each logical +conversation entry reaches scrollback exactly once. + +--- + +## 6. Streaming, Width Changes, and Tradeoffs + +### 6.1 Streaming cells + +Streaming agent responses are represented as a sequence of history entries: + +- The first chunk produces a “first line” entry for the message. +- Subsequent chunks produce continuation entries that extend that message. + +From the history/scrollback perspective: + +- Each streaming chunk is just another entry in the logical transcript. +- The high‑water mark is a simple count of how many entries at the *front* of the transcript have + already been printed. +- As new streaming chunks arrive, they are appended as new entries and will be included the next + time we print history on suspend or exit. + +We do **not** attempt to reprint or retroactively merge older chunks. In scrollback you will see the +streaming response as a series of discrete blocks, matching the internal history structure. + +Today, streaming rendering still “bakes in” some width at the time chunks are committed: line breaks +for the streaming path are computed using the width that was active at the time, and stored in the +intermediate representation. This is a known limitation and is called out in more detail in +`codex-rs/tui/streaming_wrapping_design.md`; a follow‑up change will make streaming behavior match +the rest of the transcript more closely (wrap only at display time, not at commit time). + +### 6.2 Width changes over time + +Because we now use a **cell‑level** high‑water mark instead of a visual line‑count, width changes +are handled gracefully: + +- On every suspend/exit, we render the not‑yet‑printed suffix of the transcript at the **current** + width and append those lines. +- Previously printed entries remain in scrollback with whatever wrapping they had at the time they + were printed. +- We no longer rely on “N lines printed before, therefore skip N lines of the newly wrapped + transcript,” which was the source of dropped and duplicated content when widths changed. + +This does mean scrollback can contain older cells wrapped for narrower or wider widths than the +final terminal size, but: + +- Each logical cell’s content appears exactly once. +- New cells are append‑only and never overwrite or implicitly “shrink” earlier content. +- The on‑screen TUI always reflows to the current width independently of scrollback. + +If we later choose to also re‑emit the “currently streaming” cell when printing on suspend (to make +sure the latest chunk of a long answer is always visible in scrollback), that would intentionally +duplicate a small number of lines at the boundary of that cell. The design assumes any such behavior +would be controlled by configuration (for example, by disabling suspend‑time printing entirely for +users who prefer only exit‑time output). + +### 6.3 Why not reflow scrollback? + +In theory we could try to reflow already‑printed content when widths change by: + +- Recomputing the entire transcript at the new width, and +- Printing diffs that “rewrite” old regions in scrollback. + +In practice, this runs into the same issues that motivated the redesign: + +- Terminals treat full clears and scroll regions differently. +- There is no portable way to “rewrite” arbitrary portions of scrollback above the visible buffer. +- Interleaving user output (e.g. shell prompts after suspend) makes it impossible to reliably + reconstruct the original scrollback structure. + +We therefore deliberately accept that scrollback is **append‑only** and not subject to reflow; +correctness is measured in terms of logical transcript content, not pixel‑perfect layout. + +--- + +## 7. Backtrack and Overlays (Context) + +While this document is focused on viewport and history, it’s worth mentioning a few related +behaviors that rely on the same model. + +### 7.1 Transcript overlay and backtrack + +The transcript overlay (pager) is a full‑screen view of the same logical transcript: + +- When opened, it takes a snapshot of the current transcript and renders it in an alt‑screen + overlay. +- Backtrack mode (`Esc` sequences) walks backwards through user messages in that snapshot and + highlights the candidate “edit from here” point. +- Confirming a backtrack request forks the conversation on the server and trims the in‑memory + transcript so that only history up to the chosen user message remains, then re‑renders that prefix + in the main view. + +The overlay is purely a different *view* of the same transcript; it never infers anything from +scrollback. + +--- + +## 8. Summary of Tradeoffs + +**What we gain:** + +- The TUI has a clear, single source of truth for history (the in‑memory transcript). +- Viewport rendering is deterministic and independent of scrollback. +- Suspend and exit flows: + - Print each logical history cell exactly once. + - Are robust to terminal width changes. + - Interact cleanly with alt screen and raw‑mode toggling. +- Streaming, overlays, selection, and backtrack all share the same logical history model. +- Because cells are always re‑rendered live from the transcript, per‑cell interactions can become + richer over time. Instead of treating the transcript as “dead text”, we can make individual + entries interactive after they are rendered: expanding or contracting tool calls, diffs, or + reasoning summaries in place, jumping between turns via keyboard shortcuts, or operating directly + on a selected turn (for example, forking a new session from that turn once full‑session forking + is available). + +**What we accept:** + +- Scrollback may show older cells wrapped at different widths than later cells. +- Streaming responses appear as a sequence of blocks corresponding to the chunk structure, not as a + single retroactively reflowed paragraph. +- We do not attempt to “fix up” old scrollback when the terminal is resized or when the user types + commands in between TUI runs. + +Overall, the design moves responsibility for correctness from the terminal to the TUI. The +transcript in memory is authoritative; scrollback is a best‑effort, append‑only projection that +always contains all logical content without silent loss or uncontrolled duplication. This is a much +better foundation for future TUI work than trying to treat the terminal’s scrollback as a mutable +data structure. + +--- + +## 9. Implementation Notes (for maintainers) + +For readers who want to connect this design back to the code, here are the main touchpoints. The +names and paths here are intentionally low‑level; they are not required to understand the design, +but they are useful when navigating the implementation. + +- **Transcript representation** + - `App::transcript_cells: Vec>` in `codex-rs/tui/src/app.rs`. + - `HistoryCell` trait and concrete cells (user, agent, system/info) in + `codex-rs/tui/src/history_cell.rs`. + +- **Viewport flattening and scroll state** + - `build_transcript_lines` and `render_transcript_cells` in `app.rs` implement the “flatten cells + into visual lines + metadata” step and the main transcript rendering. + - `TranscriptScroll` and `TranscriptSelection` in `app.rs` hold scroll and selection anchors. + +- **Mouse, selection, and copy** + - Mouse handling is wired through `App::handle_tui_event` and `handle_mouse_event` in `app.rs`. + - Selection highlighting is applied in `apply_transcript_selection`. + - Copy logic (selection → clipboard) lives in `copy_transcript_selection` in `app.rs` and the + helper module `codex-rs/tui/src/clipboard_copy.rs`. + +- **Alt screen, viewport, and suspend** + - The terminal wrapper lives in `codex-rs/tui/src/tui.rs` and + `codex-rs/tui/src/custom_terminal.rs`. + - Entering/leaving alt screen and managing the inline viewport is handled by `Tui::enter_alt_screen`, + `Tui::leave_alt_screen`, and `Tui::draw`. + - Suspend/resume coordination is in `tui::job_control::SuspendContext` and the + `Tui::suspend` / `Tui::event_stream` plumbing. + +- **Printing history to scrollback** + - The cell‑based high‑water mark is `App::printed_history_cells` in `app.rs`. + - `render_cells_to_ansi`, `render_lines_to_ansi`, and `build_transcript_lines` are the key + helpers for turning transcript cells into ANSI‑styled lines. + - Suspend/exit flows that print history are implemented in `App::handle_suspend`, + `App::prepare_suspend_history`, and the `session_lines` construction at the end of `App::run`, + plus the `main.rs` wrappers in `codex-rs/tui` and `codex-rs/cli` that actually print those + lines. + +This is not an exhaustive list, but it should be enough for a new contributor to orient themselves +and trace the design back to concrete code.*** + +--- + +## 10. Future Work and Open Questions + +This section collects design questions that follow naturally from the current model and are worth +explicit discussion before we commit to further UI changes. + +- **“Scroll mode” vs “live follow” UI.** + - We already distinguish “scrolled away from bottom” vs “following the latest output” in the + footer and scroll state. Do we need a more explicit “scroll mode vs live mode” affordance (e.g., + a dedicated indicator or toggle), or is the current behavior sufficient and adding more chrome + would be noise? + +- **Ephemeral scroll indicator.** + - For long sessions, a more visible sense of “where am I?” could help. One option is a minimalist + scrollbar that appears while the user is actively scrolling and fades out when idle. A full + “mini‑map” is probably too heavy for a TUI given the limited vertical space, but we could + imagine adding simple markers along the scrollbar to show where prior prompts occurred, or + where text search matches are, without trying to render a full preview of the buffer. + +- **Selection affordances.** + - Today, the primary hint that selection is active is the reversed text and the “Ctrl+Y copy + selection” footer text. Do we want an explicit “Selecting… (Esc to cancel)” status while a drag + is in progress, or would that be redundant/clutter for most users? + +- **Suspend banners in scrollback.** + - When printing history on suspend, should we also emit a small banner such as + `--- codex suspended; history up to here ---` to make those boundaries obvious in scrollback? + This would slightly increase noise but could make multi‑suspend sessions easier to read. + +- **Configuring suspend printing behavior.** + - The design already assumes that suspend‑time printing can be gated by config. Questions to + resolve: + - Should printing on suspend be on or off by default? + - Should we support multiple modes (e.g., “off”, “print all new cells”, “print streaming cell + tail only”) or keep it binary? + +- **Streaming duplication at the edges.** + - If we later choose to always re‑emit the “currently streaming” message when printing on suspend, + we would intentionally allow a small amount of duplication at the boundary of that message (for + example, its last line appearing twice across suspends). Is that acceptable if it improves the + readability of long streaming answers in scrollback, and should the ability to disable + suspend‑time printing be our escape hatch for users who care about exact de‑duplication?*** From eac367c410170684a2d0689daf6270477f639529 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 4 Dec 2025 11:42:22 -0800 Subject: [PATCH 18/20] tui: lift bottom pane with short transcript Adjust the main TUI layout so that when the transcript has fewer lines than the available viewport, the bottom chat pane is placed directly under the rendered transcript instead of being pegged to the bottom of the terminal. This avoids a large blank gap on tall terminals when a session is just starting, while preserving the previous behavior once the transcript is long enough to fill the space above the chat. The transcript renderer now: - Computes the actual number of visible transcript lines and uses that to determine the chat pane's top row. - Clears only the region above the chat and then renders the transcript into that area. - Returns the computed chat top position so the draw loop can render the chat there and clear any remaining rows below it. Scroll state and selection continue to operate on flattened transcript lines; this change only affects vertical placement of the chat pane when the transcript is short. --- codex-rs/tui/src/app.rs | 155 +++++++++++++++++++++++----------------- 1 file changed, 90 insertions(+), 65 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a87c867b026..0811a39a721 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -515,45 +515,57 @@ impl App { return Ok(true); } let cells = self.transcript_cells.clone(); - tui.draw( - //self.chat_widget.desired_height(tui.terminal.size()?.width), - tui.terminal.size()?.height, - |frame| { - let chat_height = self.chat_widget.desired_height(frame.area().width); - // peg chat to the bottom - let chat_area = Rect { - x: frame.area().x, - y: frame.area().bottom().saturating_sub(chat_height), - width: frame.area().width, - height: chat_height, - }; - self.chat_widget.render(chat_area, frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { - frame.set_cursor_position((x, y)); - } - self.render_transcript_cells(frame, &cells); - - let transcript_scrolled = - !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); - let selection_active = matches!( - (self.transcript_selection.anchor, self.transcript_selection.head), - (Some(a), Some(b)) if a != b - ); - let scroll_position = if self.transcript_total_lines == 0 { - None - } else { - Some(( - self.transcript_view_top.saturating_add(1), - self.transcript_total_lines, - )) - }; - self.chat_widget.set_transcript_ui_state( - transcript_scrolled, - selection_active, - scroll_position, + tui.draw(tui.terminal.size()?.height, |frame| { + let chat_height = self.chat_widget.desired_height(frame.area().width); + let chat_top = self.render_transcript_cells(frame, &cells, chat_height); + let chat_area = Rect { + x: frame.area().x, + y: chat_top, + width: frame.area().width, + height: chat_height.min( + frame + .area() + .height + .saturating_sub(chat_top.saturating_sub(frame.area().y)), + ), + }; + self.chat_widget.render(chat_area, frame.buffer); + let chat_bottom = chat_area.y.saturating_add(chat_area.height); + if chat_bottom < frame.area().bottom() { + Clear.render_ref( + Rect { + x: frame.area().x, + y: chat_bottom, + width: frame.area().width, + height: frame.area().bottom().saturating_sub(chat_bottom), + }, + frame.buffer, ); - }, - )?; + } + if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) { + frame.set_cursor_position((x, y)); + } + + let transcript_scrolled = + !matches!(self.transcript_scroll, TranscriptScroll::ToBottom); + let selection_active = matches!( + (self.transcript_selection.anchor, self.transcript_selection.head), + (Some(a), Some(b)) if a != b + ); + let scroll_position = if self.transcript_total_lines == 0 { + None + } else { + Some(( + self.transcript_view_top.saturating_add(1), + self.transcript_total_lines, + )) + }; + self.chat_widget.set_transcript_ui_state( + transcript_scrolled, + selection_active, + scroll_position, + ); + })?; } } @@ -564,43 +576,27 @@ impl App { &mut self, frame: &mut Frame, cells: &[Arc], - ) { + chat_height: u16, + ) -> u16 { let area = frame.area(); if area.width == 0 || area.height == 0 { - return; - } - - let chat_height = self.chat_widget.desired_height(area.width); - if chat_height >= area.height { - return; + return area.bottom().saturating_sub(chat_height); } - let transcript_height = area.height.saturating_sub(chat_height); - if transcript_height == 0 { - return; - } - - if cells.is_empty() { - Clear.render_ref( - Rect { - x: area.x, - y: area.y, - width: area.width, - height: transcript_height, - }, - frame.buffer, - ); + let chat_height = chat_height.min(area.height); + let max_transcript_height = area.height.saturating_sub(chat_height); + if max_transcript_height == 0 { self.transcript_scroll = TranscriptScroll::ToBottom; self.transcript_view_top = 0; self.transcript_total_lines = 0; - return; + return area.y; } let transcript_area = Rect { x: area.x, y: area.y, width: area.width, - height: transcript_height, + height: max_transcript_height, }; let (lines, meta) = build_transcript_lines(cells, transcript_area.width); @@ -615,12 +611,12 @@ impl App { self.transcript_scroll = TranscriptScroll::ToBottom; self.transcript_view_top = 0; self.transcript_total_lines = 0; - return; + return area.y; } let total_lines = lines.len(); self.transcript_total_lines = total_lines; - let max_visible = transcript_area.height as usize; + let max_visible = std::cmp::min(max_transcript_height as usize, total_lines); let max_start = total_lines.saturating_sub(max_visible); let top_offset = match self.transcript_scroll { @@ -648,7 +644,35 @@ impl App { }; self.transcript_view_top = top_offset; - Clear.render_ref(transcript_area, frame.buffer); + let transcript_visible_height = max_visible as u16; + let chat_top = if total_lines <= max_transcript_height as usize { + let gap = if transcript_visible_height == 0 { 0 } else { 1 }; + area.y + .saturating_add(transcript_visible_height) + .saturating_add(gap) + } else { + area.bottom().saturating_sub(chat_height) + }; + + let clear_height = chat_top.saturating_sub(area.y); + if clear_height > 0 { + Clear.render_ref( + Rect { + x: area.x, + y: area.y, + width: area.width, + height: clear_height, + }, + frame.buffer, + ); + } + + let transcript_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: transcript_visible_height, + }; for (row_index, line_index) in (top_offset..total_lines).enumerate() { if row_index >= max_visible { @@ -675,6 +699,7 @@ impl App { } self.apply_transcript_selection(transcript_area, frame.buffer); + chat_top } fn apply_transcript_selection(&self, area: Rect, buf: &mut Buffer) { From 7a814b470e2f60e16441834994a76ff2e4799d41 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 4 Dec 2025 12:20:26 -0800 Subject: [PATCH 19/20] tui: restore mouse wheel scrolling in overlays Hook crossterm mouse wheel events into the transcript and full-screen pager overlays so that scrolling is handled by the TUI itself instead of relying on terminal "alternate scroll" behavior. - Wire TuiEvent::Mouse into TranscriptOverlay and StaticOverlay so PagerView updates its scroll_offset on ScrollUp/ScrollDown. - Use the same 3-line wheel step as the main transcript view for consistency between inline and overlay scrolling. - Remove the custom Enable/DisableAlternateScroll commands and calls; the TUI now stays in application mouse mode and treats wheel events uniformly across inline and alt-screen views. This keeps scrolling behavior predictable across terminals and restores mouse scrolling in transcript/diff/approval overlays after disabling alternate scroll in the main TUI. --- codex-rs/tui/src/pager_overlay.rs | 22 +++++++++++++ codex-rs/tui/src/tui.rs | 49 +---------------------------- codex-rs/tui/src/tui/job_control.rs | 10 ++---- 3 files changed, 25 insertions(+), 56 deletions(-) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3b47e9a70ef..1e9476967cb 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -14,6 +14,8 @@ use crate::tui; use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; use ratatui::buffer::Buffer; use ratatui::buffer::Cell; use ratatui::layout::Rect; @@ -263,6 +265,24 @@ impl PagerView { Ok(()) } + fn handle_mouse_scroll(&mut self, tui: &mut tui::Tui, event: MouseEvent) -> Result<()> { + let step: usize = 3; + match event.kind { + MouseEventKind::ScrollUp => { + self.scroll_offset = self.scroll_offset.saturating_sub(step); + } + MouseEventKind::ScrollDown => { + self.scroll_offset = self.scroll_offset.saturating_add(step); + } + _ => { + return Ok(()); + } + } + tui.frame_requester() + .schedule_frame_in(Duration::from_millis(16)); + Ok(()) + } + fn update_last_content_height(&mut self, height: u16) { self.last_content_height = Some(height as usize); } @@ -476,6 +496,7 @@ impl TranscriptOverlay { } other => self.view.handle_key_event(tui, other), }, + TuiEvent::Mouse(mouse_event) => self.view.handle_mouse_scroll(tui, mouse_event), TuiEvent::Draw => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); @@ -535,6 +556,7 @@ impl StaticOverlay { } other => self.view.handle_key_event(tui, other), }, + TuiEvent::Mouse(mouse_event) => self.view.handle_mouse_scroll(tui, mouse_event), TuiEvent::Draw => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index da830ea0c9d..9aefa38f1f9 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -72,57 +72,12 @@ pub fn set_modes() -> Result<()> { ); let _ = execute!(stdout(), EnableFocusChange); - // Ensure any pre-existing alternate scroll mode is disabled so mouse - // wheel events don't get translated into Up/Down keys by the terminal, - // and enable application mouse mode so scroll events are delivered as + // Enable application mouse mode so scroll events are delivered as // Mouse events instead of arrow keys. - let _ = execute!(stdout(), DisableAlternateScroll); let _ = execute!(stdout(), EnableMouseCapture); Ok(()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct EnableAlternateScroll; - -impl Command for EnableAlternateScroll { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b[?1007h") - } - - #[cfg(windows)] - fn execute_winapi(&self) -> Result<()> { - Err(std::io::Error::other( - "tried to execute EnableAlternateScroll using WinAPI; use ANSI instead", - )) - } - - #[cfg(windows)] - fn is_ansi_code_supported(&self) -> bool { - true - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct DisableAlternateScroll; - -impl Command for DisableAlternateScroll { - fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { - write!(f, "\x1b[?1007l") - } - - #[cfg(windows)] - fn execute_winapi(&self) -> Result<()> { - Err(std::io::Error::other( - "tried to execute DisableAlternateScroll using WinAPI; use ANSI instead", - )) - } - - #[cfg(windows)] - fn is_ansi_code_supported(&self) -> bool { - true - } -} - /// Restore the terminal to its original state. /// Inverse of `set_modes`. pub fn restore() -> Result<()> { @@ -329,8 +284,6 @@ impl Tui { /// Leave alternate screen and restore the previously saved inline viewport, if any. pub fn leave_alt_screen(&mut self) -> Result<()> { - // Disable alternate scroll when leaving alt-screen - let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll); let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); if let Some(saved) = self.alt_saved_viewport.take() { self.terminal.set_viewport_area(saved); diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs index fd4850a2372..686ba203760 100644 --- a/codex-rs/tui/src/tui/job_control.rs +++ b/codex-rs/tui/src/tui/job_control.rs @@ -16,11 +16,8 @@ use ratatui::crossterm::execute; use ratatui::layout::Position; use ratatui::layout::Rect; -use crate::key_hint; - -use super::DisableAlternateScroll; -use super::EnableAlternateScroll; use super::Terminal; +use crate::key_hint; pub const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z')); @@ -66,8 +63,7 @@ impl SuspendContext { /// - Trigger SIGTSTP so the process can be resumed and continue drawing with the saved state. pub(crate) fn suspend(&self, alt_screen_active: &Arc) -> Result<()> { if alt_screen_active.load(Ordering::Relaxed) { - // Leave alt-screen so the terminal returns to the normal buffer while suspended; also turn off alt-scroll. - let _ = execute!(stdout(), DisableAlternateScroll); + // Leave alt-screen so the terminal returns to the normal buffer while suspended. let _ = execute!(stdout(), LeaveAlternateScreen); self.set_resume_action(ResumeAction::RestoreAlt); } else { @@ -196,8 +192,6 @@ impl PreparedResumeAction { } PreparedResumeAction::RestoreAltScreen => { execute!(terminal.backend_mut(), EnterAlternateScreen)?; - // Enable "alternate scroll" so terminals may translate wheel to arrows - execute!(terminal.backend_mut(), EnableAlternateScroll)?; if let Ok(size) = terminal.size() { terminal.set_viewport_area(Rect::new(0, 0, size.width, size.height)); terminal.clear()?; From ebd8c2aa77d0d9e26dcacc4ced93a7fa9d89c47f Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 4 Dec 2025 12:59:21 -0800 Subject: [PATCH 20/20] tui: make transcript selection-friendly while streaming Refine mouse-driven transcript scrolling so that auto-follow only disengages when it matters for selection: - When a mouse selection begins while a task/stream is running and the view is following the bottom, convert the scroll mode into an anchored position so streaming output no longer moves the viewport under the selection. - If streaming is not active, starting a selection leaves scroll mode as-is; there is nothing to auto-follow. - If streaming continues and the user scrolls back to the bottom (via wheel or PgDn/End), scroll mode returns to "follow latest" and auto-follow resumes even if a selection remains. The behavior is documented on the mouse and scroll helpers so that the interaction between streaming, selection, and the state is clear to future readers. --- codex-rs/tui/src/app.rs | 79 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0811a39a721..5a2b1ff32d3 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -777,6 +777,18 @@ impl App { } } + /// Handle mouse interaction in the main transcript view. + /// + /// - Mouse wheel movement scrolls the conversation history by small, fixed increments, + /// independent of the terminal's own scrollback. + /// - Mouse clicks and drags adjust a text selection defined in terms of transcript + /// lines and columns, so the selection is anchored to the underlying content rather + /// than to absolute screen rows. + /// - When a selection begins while the view is following the bottom and a task is + /// actively running (e.g., streaming a response), the scroll mode is first converted + /// into an anchored position so that ongoing updates no longer move the viewport + /// under the selection. If no task is running, starting a selection leaves scroll + /// behavior unchanged. fn handle_mouse_event(&mut self, tui: &mut tui::Tui, event: crossterm::event::MouseEvent) { if self.overlay.is_some() { return; @@ -822,9 +834,7 @@ impl App { clamped_x = max_x; } - let streaming = self.chat_widget.is_task_running() - && matches!(self.transcript_scroll, TranscriptScroll::ToBottom); - + let streaming = self.chat_widget.is_task_running(); let row_index = usize::from(clamped_y.saturating_sub(transcript_area.y)); let line_index = self.transcript_view_top.saturating_add(row_index); @@ -836,13 +846,20 @@ impl App { self.scroll_transcript(tui, 3); } crossterm::event::MouseEventKind::Down(MouseButton::Left) => { - if !streaming { - self.transcript_selection.anchor = Some((line_index, clamped_x)); - self.transcript_selection.head = Some((line_index, clamped_x)); + if streaming && matches!(self.transcript_scroll, TranscriptScroll::ToBottom) { + self.lock_transcript_scroll_to_current_view(width); } + self.transcript_selection.anchor = Some((line_index, clamped_x)); + self.transcript_selection.head = Some((line_index, clamped_x)); } crossterm::event::MouseEventKind::Drag(MouseButton::Left) => { - if !streaming && self.transcript_selection.anchor.is_some() { + if streaming + && matches!(self.transcript_scroll, TranscriptScroll::ToBottom) + && self.transcript_selection.anchor.is_some() + { + self.lock_transcript_scroll_to_current_view(width); + } + if self.transcript_selection.anchor.is_some() { self.transcript_selection.head = Some((line_index, clamped_x)); } } @@ -855,6 +872,12 @@ impl App { } } + /// Scroll the transcript by a fixed number of visual lines. + /// + /// This is the shared implementation behind mouse wheel movement and PgUp/PgDn keys in + /// the main view. Scroll state is expressed in terms of transcript cells and their + /// internal line indices, so scrolling refers to logical conversation content and + /// remains stable even as wrapping or streaming causes visual reflows. fn scroll_transcript(&mut self, tui: &tui::Tui, delta_lines: isize) { if self.transcript_cells.is_empty() { return; @@ -954,6 +977,48 @@ impl App { tui.frame_requester().schedule_frame(); } + /// Convert a `ToBottom` (auto-follow) scroll state into a fixed anchor at the current view. + /// + /// When the user begins a mouse selection while new output is streaming in, the view + /// should stop auto-following the latest line so the selection stays on the intended + /// content. This helper inspects the flattened transcript at the given width, derives + /// a concrete position corresponding to the current `transcript_view_top`, and switches + /// into a scroll mode that keeps that position stable until the user scrolls again. + fn lock_transcript_scroll_to_current_view(&mut self, width: u16) { + if self.transcript_cells.is_empty() || width == 0 { + return; + } + + let (lines, meta) = build_transcript_lines(&self.transcript_cells, width); + if lines.is_empty() || meta.is_empty() { + return; + } + + let mut anchor = None; + let start = self.transcript_view_top.min(meta.len().saturating_sub(1)); + for idx in start..meta.len() { + if let Some((cell_index, line_in_cell)) = meta[idx] { + anchor = Some((cell_index, line_in_cell)); + break; + } + } + if anchor.is_none() { + for idx in (0..=start).rev() { + if let Some((cell_index, line_in_cell)) = meta[idx] { + anchor = Some((cell_index, line_in_cell)); + break; + } + } + } + + if let Some((cell_index, line_in_cell)) = anchor { + self.transcript_scroll = TranscriptScroll::Scrolled { + cell_index, + line_in_cell, + }; + } + } + async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::NewSession => {