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
This commit is contained in:
@@ -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<Line<'static>>,
|
||||
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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,
|
||||
..
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
230
codex-rs/tui/src/transcript_app.rs
Normal file
230
codex-rs/tui/src/transcript_app.rs
Normal file
@@ -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<Line<'static>>) {
|
||||
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<Line<'static>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user