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:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user