diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4c87b8dc05..87740b9b12 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/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2b066197af..1cc3293f58 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -301,6 +301,12 @@ 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}"); + } + if !exit_info.session_lines.is_empty() { + println!(); + } for line in format_exit_messages(exit_info, color_enabled) { println!("{line}"); } @@ -764,6 +770,7 @@ mod tests { .map(ConversationId::from_string) .map(Result::unwrap), update_action: None, + session_lines: Vec::new(), } } @@ -773,6 +780,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/Cargo.toml b/codex-rs/tui/Cargo.toml index d9906b2f01..be4f5aead7 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/app.rs b/codex-rs/tui/src/app.rs index d0e057102c..5a2b1ff32d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,10 +3,13 @@ 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; 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; @@ -42,9 +45,15 @@ use color_eyre::eyre::WrapErr; 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::text::Line; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::path::PathBuf; use std::sync::Arc; @@ -66,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( @@ -184,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(), }); } } @@ -205,6 +216,11 @@ pub(crate) struct App { pub(crate) file_search: FileSearchManager, pub(crate) transcript_cells: Vec>, + transcript_scroll: TranscriptScroll, + transcript_selection: TranscriptSelection, + printed_history_cells: usize, + transcript_view_top: usize, + transcript_total_lines: usize, // Pager overlay state (Transcript or Static like Diff) pub(crate) overlay: Option, @@ -230,6 +246,27 @@ 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 + } +} + +#[derive(Debug, Clone, Copy, Default)] +struct TranscriptSelection { + anchor: Option<(usize, u16)>, + head: Option<(usize, u16)>, +} + impl App { async fn shutdown_current_conversation(&mut self) { if let Some(conversation_id) = self.chat_widget.conversation_id() { @@ -328,6 +365,11 @@ impl App { file_search, enhanced_keys_supported, 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, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -388,14 +430,54 @@ 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 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()?; Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), update_action: app.pending_update_action, + session_lines, }) } + 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, @@ -403,40 +485,538 @@ impl App { ) -> Result { if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; + return Ok(true); + } + + 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(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, + ); + })?; + } + } + + Ok(true) + } + + pub(crate) fn render_transcript_cells( + &mut self, + frame: &mut Frame, + cells: &[Arc], + chat_height: u16, + ) -> u16 { + let area = frame.area(); + if area.width == 0 || area.height == 0 { + return area.bottom().saturating_sub(chat_height); + } + + 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 area.y; + } + + let transcript_area = Rect { + x: area.x, + y: area.y, + width: area.width, + height: max_transcript_height, + }; + + 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; + self.transcript_view_top = 0; + self.transcript_total_lines = 0; + return area.y; + } + + let total_lines = lines.len(); + self.transcript_total_lines = total_lines; + 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 { + 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 + } + } + }; + self.transcript_view_top = top_offset; + + 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 { - match event { - TuiEvent::Key(key_event) => { - self.handle_key_event(tui, key_event).await; + 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 { + 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, + }; + 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); + } + + self.apply_transcript_selection(transcript_area, frame.buffer); + chat_top + } + + 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_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() { + 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; + } + + 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 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 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)); + } + } + } + + /// 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; + } + + 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; + } + + 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); + + match event.kind { + crossterm::event::MouseEventKind::ScrollUp => { + self.scroll_transcript(tui, -3); + } + crossterm::event::MouseEventKind::ScrollDown => { + self.scroll_transcript(tui, 3); + } + crossterm::event::MouseEventKind::Down(MouseButton::Left) => { + if streaming && matches!(self.transcript_scroll, TranscriptScroll::ToBottom) { + self.lock_transcript_scroll_to_current_view(width); } - 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); + 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 + && matches!(self.transcript_scroll, TranscriptScroll::ToBottom) + && self.transcript_selection.anchor.is_some() + { + self.lock_transcript_scroll_to_current_view(width); } - TuiEvent::Draw => { - self.chat_widget.maybe_post_pending_notification(tui); - if self - .chat_widget - .handle_paste_burst_tick(tui.frame_requester()) - { - return Ok(true); + if self.transcript_selection.anchor.is_some() { + self.transcript_selection.head = Some((line_index, clamped_x)); + } + } + crossterm::event::MouseEventKind::Up(MouseButton::Left) => { + if self.transcript_selection.anchor == self.transcript_selection.head { + self.transcript_selection = TranscriptSelection::default(); + } + } + _ => {} + } + } + + /// 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; + } + + 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; + } } - tui.draw( - self.chat_widget.desired_height(tui.terminal.size()?.width), - |frame| { - self.chat_widget.render(frame.area(), frame.buffer); - if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { - frame.set_cursor_position((x, y)); - } - }, - )?; } + 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(); + } + + /// 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, + }; } - Ok(true) } async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { @@ -470,8 +1050,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 +1070,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); } } } @@ -923,6 +1503,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, @@ -935,6 +1523,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, .. @@ -953,6 +1604,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, @@ -1004,6 +1814,98 @@ 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 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) +} + +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)>], + is_user_cell: &[bool], + width: u16, +) -> Vec { + lines + .iter() + .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 { + style: s.style.patch(line.style), + 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() + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -1048,6 +1950,11 @@ mod tests { active_profile: None, file_search, 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, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, @@ -1085,6 +1992,11 @@ mod tests { active_profile: None, file_search, 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, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b161867e44..a0f17b3110 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/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6b42e5134c..4d4e478ecd 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; @@ -113,6 +114,10 @@ pub(crate) struct ChatComposer { footer_mode: FooterMode, footer_hint_override: Option>, context_window_percent: Option, + 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. @@ -156,6 +161,10 @@ impl ChatComposer { footer_mode: FooterMode::ShortcutSummary, footer_hint_override: None, context_window_percent: None, + 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); @@ -1387,6 +1396,10 @@ 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, + transcript_scroll_position: self.transcript_scroll_position, + copy_status: self.copy_status, } } @@ -1517,6 +1530,24 @@ impl ChatComposer { self.is_task_running = running; } + 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; + 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) { 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 79d7c60fa7..8195ed9c3f 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, @@ -18,6 +24,10 @@ 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, + pub(crate) transcript_scroll_position: Option<(usize, usize)>, + pub(crate) copy_status: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -87,6 +97,33 @@ 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(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 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()); + 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 { @@ -400,6 +437,10 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + copy_status: None, }, ); @@ -411,6 +452,10 @@ mod tests { use_shift_enter_hint: true, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + copy_status: None, }, ); @@ -422,6 +467,10 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + copy_status: None, }, ); @@ -433,6 +482,10 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + copy_status: None, }, ); @@ -444,6 +497,10 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + copy_status: None, }, ); @@ -455,6 +512,10 @@ mod tests { use_shift_enter_hint: false, is_task_running: false, context_window_percent: None, + transcript_scrolled: false, + transcript_selection_active: false, + transcript_scroll_position: None, + copy_status: None, }, ); @@ -466,6 +527,10 @@ mod tests { use_shift_enter_hint: false, is_task_running: true, context_window_percent: Some(72), + 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 5dbfb210b2..fe6e2af35a 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; @@ -366,6 +367,22 @@ impl BottomPane { self.request_redraw(); } + 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, scroll_position); + 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 fbabbc300e..cd4a221467 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 @@ -2845,6 +2854,20 @@ 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, + scroll_position: Option<(usize, usize)>, + ) { + self.bottom_pane + .set_transcript_ui_state(scrolled, selection_active, scroll_position); + } /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { // Record outbound operation for session replay fidelity. diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs new file mode 100644 index 0000000000..76718704e5 --- /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/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 36ef47da5e..e0e2731678 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/lib.rs b/codex-rs/tui/src/lib.rs index 33bd18c437..3b8c0be493 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; @@ -373,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(), }); } } @@ -412,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 @@ -447,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(), }); } } @@ -485,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, @@ -495,6 +500,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 +516,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/main.rs b/codex-rs/tui/src/main.rs index 50ea95f170..ae7dbaa137 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -22,6 +22,12 @@ 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}"); + } + 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),); diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 283007e028..828e45286d 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -103,11 +103,16 @@ 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()); }); } + 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 47c7811a3b..f7aa80cd16 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| { @@ -420,6 +424,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/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3b47e9a70e..1e9476967c 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 7cbf252e7f..9aefa38f1f 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -9,15 +9,15 @@ 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; 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; @@ -26,16 +26,18 @@ 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; 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 +45,7 @@ use crate::tui::job_control::SUSPEND_KEY; #[cfg(unix)] use crate::tui::job_control::SuspendContext; +mod frame_requester; #[cfg(unix)] mod job_control; @@ -69,56 +72,18 @@ pub fn set_modes() -> Result<()> { ); let _ = execute!(stdout(), EnableFocusChange); + // Enable application mouse mode so scroll events are delivered as + // Mouse events instead of arrow keys. + 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<()> { // 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()?; @@ -156,11 +121,13 @@ pub enum TuiEvent { Key(KeyEvent), Paste(String), Draw, + Mouse(crossterm::event::MouseEvent), + Suspend, } 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 +140,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 +153,7 @@ impl Tui { let _ = crate::terminal_palette::default_colors(); Self { - frame_schedule_tx, + frame_requester, draw_tx, terminal, pending_history_lines: vec![], @@ -226,9 +167,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 { @@ -252,12 +191,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 { @@ -267,9 +200,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); @@ -280,6 +211,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(); @@ -288,7 +222,6 @@ impl Tui { Event::FocusLost => { terminal_focused.store(false, Ordering::Relaxed); } - _ => {} } } result = draw_rx.recv() => { @@ -311,12 +244,30 @@ 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<()> { 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( @@ -333,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); @@ -362,34 +311,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); @@ -402,25 +331,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)] @@ -440,51 +369,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 0000000000..4f7886aa22 --- /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"); + } +} diff --git a/codex-rs/tui/src/tui/job_control.rs b/codex-rs/tui/src/tui/job_control.rs index 3680419489..686ba20376 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')); @@ -45,6 +42,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 +51,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())), } } @@ -63,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 { @@ -72,7 +71,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 +126,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 +149,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. @@ -157,11 +188,10 @@ impl PreparedResumeAction { match self { PreparedResumeAction::RealignViewport(area) => { terminal.set_viewport_area(area); + terminal.clear()?; } 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()?; @@ -171,12 +201,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(()) -} diff --git a/codex-rs/tui/streaming_wrapping_design.md b/codex-rs/tui/streaming_wrapping_design.md new file mode 100644 index 0000000000..04511f98fe --- /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. + diff --git a/docs/tui_viewport_and_history.md b/docs/tui_viewport_and_history.md new file mode 100644 index 0000000000..5786a91774 --- /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?***