From 4b4aa2a77496e0a0f5229b4502c80c82aea6c543 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 21 Aug 2025 11:17:29 -0700 Subject: [PATCH] tui: transcript mode updates live (#2562) moves TranscriptApp to be an "overlay", and continue to pump AppEvents while the transcript is active, but forward all tui handling to the transcript screen. --- codex-rs/tui/src/app.rs | 121 +++++++++++++++++++++-------- codex-rs/tui/src/transcript_app.rs | 81 +++++++------------ 2 files changed, 120 insertions(+), 82 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 971d1d16..e706fa72 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2,7 +2,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; -use crate::transcript_app::run_transcript_app; +use crate::transcript_app::TranscriptApp; use crate::tui; use crate::tui::TuiEvent; use codex_core::ConversationManager; @@ -12,7 +12,11 @@ use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::execute; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; +use ratatui::layout::Rect; use ratatui::text::Line; use std::path::PathBuf; use std::sync::Arc; @@ -35,6 +39,11 @@ pub(crate) struct App { transcript_lines: Vec>, + // Transcript overlay state + transcript_overlay: Option, + deferred_history_lines: Vec>, + transcript_saved_viewport: Option, + enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. @@ -76,6 +85,9 @@ impl App { file_search, enhanced_keys_supported, transcript_lines: Vec::new(), + transcript_overlay: None, + deferred_history_lines: Vec::new(), + transcript_saved_viewport: None, commit_anim_running: Arc::new(AtomicBool::new(false)), }; @@ -101,34 +113,55 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { - match event { - TuiEvent::Key(key_event) => { - self.handle_key_event(tui, key_event).await; + if let Some(overlay) = &mut self.transcript_overlay { + overlay.handle_event(tui, event)?; + if overlay.is_done { + // Exit alternate screen and restore viewport. + let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen); + if let Some(saved) = self.transcript_saved_viewport.take() { + tui.terminal.set_viewport_area(saved); + } + if !self.deferred_history_lines.is_empty() { + let lines = std::mem::take(&mut self.deferred_history_lines); + tui.insert_history_lines(lines); + } + self.transcript_overlay = None; } - 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 => { - tui.draw( - self.chat_widget.desired_height(tui.terminal.size()?.width), - |frame| { - frame.render_widget_ref(&self.chat_widget, frame.area()); - if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { - frame.set_cursor_position((x, y)); - } - }, - )?; - } - #[cfg(unix)] - TuiEvent::ResumeFromSuspend => { - let cursor_pos = tui.terminal.get_cursor_position()?; - tui.terminal - .set_viewport_area(ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0)); + tui.frame_requester().schedule_frame(); + } else { + match event { + TuiEvent::Key(key_event) => { + self.handle_key_event(tui, key_event).await; + } + 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 => { + tui.draw( + self.chat_widget.desired_height(tui.terminal.size()?.width), + |frame| { + frame.render_widget_ref(&self.chat_widget, frame.area()); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + }, + )?; + } + #[cfg(unix)] + TuiEvent::ResumeFromSuspend => { + let cursor_pos = tui.terminal.get_cursor_position()?; + tui.terminal.set_viewport_area(ratatui::layout::Rect::new( + 0, + cursor_pos.y, + 0, + 0, + )); + } } } Ok(true) @@ -149,14 +182,30 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryLines(lines) => { + if let Some(overlay) = &mut self.transcript_overlay { + overlay.insert_lines(lines.clone()); + tui.frame_requester().schedule_frame(); + } self.transcript_lines.extend(lines.clone()); - tui.insert_history_lines(lines); + if self.transcript_overlay.is_some() { + self.deferred_history_lines.extend(lines); + } else { + tui.insert_history_lines(lines); + } } AppEvent::InsertHistoryCell(cell) => { + if let Some(overlay) = &mut self.transcript_overlay { + overlay.insert_lines(cell.transcript_lines()); + tui.frame_requester().schedule_frame(); + } self.transcript_lines.extend(cell.transcript_lines()); let display = cell.display_lines(); if !display.is_empty() { - tui.insert_history_lines(display); + if self.transcript_overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } } } AppEvent::StartCommitAnimation => { @@ -243,7 +292,17 @@ impl App { kind: KeyEventKind::Press, .. } => { - run_transcript_app(tui, self.transcript_lines.clone()).await; + // Enter alternate screen and set viewport to full size. + let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen); + if let Ok(size) = tui.terminal.size() { + self.transcript_saved_viewport = Some(tui.terminal.viewport_area); + tui.terminal + .set_viewport_area(Rect::new(0, 0, size.width, size.height)); + let _ = tui.terminal.clear(); + } + + self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone())); + tui.frame_requester().schedule_frame(); } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, diff --git a/codex-rs/tui/src/transcript_app.rs b/codex-rs/tui/src/transcript_app.rs index a9b9d5d8..059e2a07 100644 --- a/codex-rs/tui/src/transcript_app.rs +++ b/codex-rs/tui/src/transcript_app.rs @@ -1,11 +1,11 @@ +use std::io::Result; + use crate::insert_history; use crate::tui; +use crate::tui::TuiEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; -use crossterm::execute; -use crossterm::terminal::EnterAlternateScreen; -use crossterm::terminal::LeaveAlternateScreen; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Color; @@ -16,52 +16,6 @@ use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; -use tokio::select; - -pub async fn run_transcript_app(tui: &mut tui::Tui, transcript_lines: Vec>) { - use tokio_stream::StreamExt; - let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen); - #[allow(clippy::unwrap_used)] - let size = tui.terminal.size().unwrap(); - let old_viewport_area = tui.terminal.viewport_area; - tui.terminal - .set_viewport_area(Rect::new(0, 0, size.width, size.height)); - let _ = tui.terminal.clear(); - - let tui_events = tui.event_stream(); - tokio::pin!(tui_events); - - tui.frame_requester().schedule_frame(); - - let mut app = TranscriptApp { - transcript_lines, - scroll_offset: usize::MAX, - is_done: false, - }; - - while !app.is_done { - select! { - Some(event) = tui_events.next() => { - match event { - crate::tui::TuiEvent::Key(key_event) => { - app.handle_key_event(tui, key_event); - tui.frame_requester().schedule_frame(); - } - crate::tui::TuiEvent::Draw => { - let _ = tui.draw(u16::MAX, |frame| { - app.render(frame.area(), frame.buffer); - }); - } - _ => {} - } - } - } - } - - let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen); - - tui.terminal.set_viewport_area(old_viewport_area); -} pub(crate) struct TranscriptApp { pub(crate) transcript_lines: Vec>, @@ -70,7 +24,32 @@ pub(crate) struct TranscriptApp { } impl TranscriptApp { - pub(crate) fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + pub(crate) fn new(transcript_lines: Vec>) -> Self { + Self { + transcript_lines, + scroll_offset: 0, + is_done: false, + } + } + + pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match event { + TuiEvent::Key(key_event) => self.handle_key_event(tui, key_event), + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + self.render(frame.area(), frame.buffer); + })?; + } + _ => {} + } + Ok(()) + } + + pub(crate) fn insert_lines(&mut self, lines: Vec>) { + self.transcript_lines.extend(lines); + } + + fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Char('q'), @@ -147,7 +126,7 @@ impl TranscriptApp { area } - pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { + fn render(&mut self, area: Rect, buf: &mut Buffer) { Span::from("/ ".repeat(area.width as usize / 2)) .dim() .render_ref(area, buf);