From 2ec5a28528c3174ec45f4814e154c096d687253f Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 20 Aug 2025 16:57:35 -0700 Subject: [PATCH] add transcript mode (#2525) this adds a new 'transcript mode' that shows the full event history in a "pager"-style interface. https://github.com/user-attachments/assets/52df7a14-adb2-4ea7-a0f9-7f5eb8235182 --- codex-rs/tui/src/app.rs | 18 ++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/transcript_app.rs | 230 +++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 codex-rs/tui/src/transcript_app.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 399acd40..a58070c0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4,6 +4,7 @@ use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; use crate::slash_command::SlashCommand; +use crate::transcript_app::run_transcript_app; use crate::tui; use crate::tui::TuiEvent; use codex_core::ConversationManager; @@ -16,6 +17,7 @@ use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::terminal::supports_keyboard_enhancement; +use ratatui::text::Line; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -35,6 +37,8 @@ pub(crate) struct App { file_search: FileSearchManager, + transcript_lines: Vec>, + enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. @@ -75,6 +79,7 @@ impl App { config, file_search, enhanced_keys_supported, + transcript_lines: Vec::new(), commit_anim_running: Arc::new(AtomicBool::new(false)), }; @@ -102,7 +107,7 @@ impl App { ) -> Result { match event { TuiEvent::Key(key_event) => { - self.handle_key_event(key_event).await; + self.handle_key_event(tui, key_event).await; } TuiEvent::Paste(pasted) => { // Many terminals convert newlines to \r when pasting (e.g., iTerm2), @@ -136,6 +141,7 @@ impl App { fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { match event { AppEvent::InsertHistory(lines) => { + self.transcript_lines.extend(lines.clone()); tui.insert_history_lines(lines); } AppEvent::StartCommitAnimation => { @@ -303,7 +309,7 @@ impl App { self.chat_widget.token_usage().clone() } - async fn handle_key_event(&mut self, key_event: KeyEvent) { + async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Char('c'), @@ -321,6 +327,14 @@ impl App { } if self.chat_widget.composer_is_empty() => { self.app_event_tx.send(AppEvent::ExitRequest); } + KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + run_transcript_app(tui, self.transcript_lines.clone()).await; + } KeyEvent { kind: KeyEventKind::Press | KeyEventKind::Repeat, .. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index ac463be0..52b1c7da 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -49,6 +49,7 @@ mod slash_command; mod status_indicator_widget; mod streaming; mod text_formatting; +mod transcript_app; mod tui; mod user_approval_widget; diff --git a/codex-rs/tui/src/transcript_app.rs b/codex-rs/tui/src/transcript_app.rs new file mode 100644 index 00000000..a9b9d5d8 --- /dev/null +++ b/codex-rs/tui/src/transcript_app.rs @@ -0,0 +1,230 @@ +use crate::insert_history; +use crate::tui; +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; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +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>, + pub(crate) scroll_offset: usize, + pub(crate) is_done: bool, +} + +impl TranscriptApp { + pub(crate) fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char('q'), + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.is_done = true; + } + KeyEvent { + code: KeyCode::Up, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + KeyEvent { + code: KeyCode::Down, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + KeyEvent { + code: KeyCode::PageUp, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let area = self.scroll_area(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); + } + KeyEvent { + code: KeyCode::PageDown, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let area = self.scroll_area(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); + } + KeyEvent { + code: KeyCode::Home, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = 0; + } + KeyEvent { + code: KeyCode::End, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = usize::MAX; + } + _ => {} + } + } + + fn scroll_area(&self, area: Rect) -> Rect { + let mut area = area; + // Reserve 1 line for the header and 4 lines for the bottom status section. This matches the chat composer. + area.y = area.y.saturating_add(1); + area.height = area.height.saturating_sub(5); + area + } + + pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { + Span::from("/ ".repeat(area.width as usize / 2)) + .dim() + .render_ref(area, buf); + Span::from("/ T R A N S C R I P T") + .dim() + .render_ref(area, buf); + + // Main content area (excludes header and bottom status section) + let content_area = self.scroll_area(area); + let wrapped = insert_history::word_wrap_lines(&self.transcript_lines, content_area.width); + + // Clamp scroll offset to valid range + self.scroll_offset = self + .scroll_offset + .min(wrapped.len().saturating_sub(content_area.height as usize)); + let start = self.scroll_offset; + let end = (start + content_area.height as usize).min(wrapped.len()); + let page = &wrapped[start..end]; + Paragraph::new(page.to_vec()).render_ref(content_area, buf); + + // Fill remaining visible lines (if any) with a leading '~' in the first column. + let visible = (end - start) as u16; + if content_area.height > visible { + let extra = content_area.height - visible; + for i in 0..extra { + let y = content_area.y.saturating_add(visible + i); + Span::from("~") + .dim() + .render_ref(Rect::new(content_area.x, y, 1, 1), buf); + } + } + + // Bottom status section (4 lines): separator with % scrolled, then key hints (styled like chat composer) + let sep_y = content_area.bottom(); + let sep_rect = Rect::new(area.x, sep_y, area.width, 1); + let hints_rect = Rect::new(area.x, sep_y + 1, area.width, 2); + + // Separator line (dim) + Span::from("─".repeat(sep_rect.width as usize)) + .dim() + .render_ref(sep_rect, buf); + + // Scroll percentage (0-100%) aligned near the right edge + let max_scroll = wrapped.len().saturating_sub(content_area.height as usize); + let percent: u8 = if max_scroll == 0 { + 100 + } else { + (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() + as u8 + }; + let pct_text = format!(" {percent}% "); + let pct_w = pct_text.chars().count() as u16; + let pct_x = sep_rect.x + sep_rect.width - pct_w - 1; + Span::from(pct_text) + .dim() + .render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf); + + let key_hint_style = Style::default().fg(Color::Cyan); + + let hints1 = vec![ + " ".into(), + "↑".set_style(key_hint_style), + "/".into(), + "↓".set_style(key_hint_style), + " scroll ".into(), + "PgUp".set_style(key_hint_style), + "/".into(), + "PgDn".set_style(key_hint_style), + " page ".into(), + "Home".set_style(key_hint_style), + "/".into(), + "End".set_style(key_hint_style), + " jump".into(), + ]; + + let hints2 = vec![" ".into(), "q".set_style(key_hint_style), " quit".into()]; + Paragraph::new(vec![Line::from(hints1).dim(), Line::from(hints2).dim()]) + .render_ref(hints_rect, buf); + } +}