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.
This commit is contained in:
Jeremy Rose
2025-08-21 11:17:29 -07:00
committed by GitHub
parent 16d16a4ddc
commit 4b4aa2a774
2 changed files with 120 additions and 82 deletions

View File

@@ -2,7 +2,7 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidget;
use crate::file_search::FileSearchManager; use crate::file_search::FileSearchManager;
use crate::transcript_app::run_transcript_app; use crate::transcript_app::TranscriptApp;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent; use crate::tui::TuiEvent;
use codex_core::ConversationManager; use codex_core::ConversationManager;
@@ -12,7 +12,11 @@ use color_eyre::eyre::Result;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::supports_keyboard_enhancement; use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Rect;
use ratatui::text::Line; use ratatui::text::Line;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
@@ -35,6 +39,11 @@ pub(crate) struct App {
transcript_lines: Vec<Line<'static>>, transcript_lines: Vec<Line<'static>>,
// Transcript overlay state
transcript_overlay: Option<TranscriptApp>,
deferred_history_lines: Vec<Line<'static>>,
transcript_saved_viewport: Option<Rect>,
enhanced_keys_supported: bool, enhanced_keys_supported: bool,
/// Controls the animation thread that sends CommitTick events. /// Controls the animation thread that sends CommitTick events.
@@ -76,6 +85,9 @@ impl App {
file_search, file_search,
enhanced_keys_supported, enhanced_keys_supported,
transcript_lines: Vec::new(), transcript_lines: Vec::new(),
transcript_overlay: None,
deferred_history_lines: Vec::new(),
transcript_saved_viewport: None,
commit_anim_running: Arc::new(AtomicBool::new(false)), commit_anim_running: Arc::new(AtomicBool::new(false)),
}; };
@@ -101,34 +113,55 @@ impl App {
tui: &mut tui::Tui, tui: &mut tui::Tui,
event: TuiEvent, event: TuiEvent,
) -> Result<bool> { ) -> Result<bool> {
match event { if let Some(overlay) = &mut self.transcript_overlay {
TuiEvent::Key(key_event) => { overlay.handle_event(tui, event)?;
self.handle_key_event(tui, key_event).await; 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) => { tui.frame_requester().schedule_frame();
// Many terminals convert newlines to \r when pasting (e.g., iTerm2), } else {
// but tui-textarea expects \n. Normalize CR to LF. match event {
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 TuiEvent::Key(key_event) => {
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 self.handle_key_event(tui, key_event).await;
let pasted = pasted.replace("\r", "\n"); }
self.chat_widget.handle_paste(pasted); TuiEvent::Paste(pasted) => {
} // Many terminals convert newlines to \r when pasting (e.g., iTerm2),
TuiEvent::Draw => { // but tui-textarea expects \n. Normalize CR to LF.
tui.draw( // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
self.chat_widget.desired_height(tui.terminal.size()?.width), // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
|frame| { let pasted = pasted.replace("\r", "\n");
frame.render_widget_ref(&self.chat_widget, frame.area()); self.chat_widget.handle_paste(pasted);
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { }
frame.set_cursor_position((x, y)); TuiEvent::Draw => {
} tui.draw(
}, self.chat_widget.desired_height(tui.terminal.size()?.width),
)?; |frame| {
} frame.render_widget_ref(&self.chat_widget, frame.area());
#[cfg(unix)] if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
TuiEvent::ResumeFromSuspend => { frame.set_cursor_position((x, y));
let cursor_pos = tui.terminal.get_cursor_position()?; }
tui.terminal },
.set_viewport_area(ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0)); )?;
}
#[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) Ok(true)
@@ -149,14 +182,30 @@ impl App {
tui.frame_requester().schedule_frame(); tui.frame_requester().schedule_frame();
} }
AppEvent::InsertHistoryLines(lines) => { 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()); 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) => { 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()); self.transcript_lines.extend(cell.transcript_lines());
let display = cell.display_lines(); let display = cell.display_lines();
if !display.is_empty() { 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 => { AppEvent::StartCommitAnimation => {
@@ -243,7 +292,17 @@ impl App {
kind: KeyEventKind::Press, 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 { KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat, kind: KeyEventKind::Press | KeyEventKind::Repeat,

View File

@@ -1,11 +1,11 @@
use std::io::Result;
use crate::insert_history; use crate::insert_history;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
use crossterm::execute;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
@@ -16,52 +16,6 @@ use ratatui::text::Line;
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef; 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) struct TranscriptApp {
pub(crate) transcript_lines: Vec<Line<'static>>, pub(crate) transcript_lines: Vec<Line<'static>>,
@@ -70,7 +24,32 @@ pub(crate) struct TranscriptApp {
} }
impl 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<Line<'static>>) -> 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<Line<'static>>) {
self.transcript_lines.extend(lines);
}
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
match key_event { match key_event {
KeyEvent { KeyEvent {
code: KeyCode::Char('q'), code: KeyCode::Char('q'),
@@ -147,7 +126,7 @@ impl TranscriptApp {
area 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)) Span::from("/ ".repeat(area.width as usize / 2))
.dim() .dim()
.render_ref(area, buf); .render_ref(area, buf);