diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d32b741c..6c978e27 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,7 +3,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::TranscriptApp; +use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; use codex_ansi_escape::ansi_escape_line; @@ -40,8 +40,8 @@ pub(crate) struct App { pub(crate) transcript_lines: Vec>, - // Transcript overlay state - pub(crate) transcript_overlay: Option, + // Pager overlay state (Transcript or Static like Diff) + pub(crate) overlay: Option, pub(crate) deferred_history_lines: Vec>, pub(crate) enhanced_keys_supported: bool, @@ -89,7 +89,7 @@ impl App { file_search, enhanced_keys_supported, transcript_lines: Vec::new(), - transcript_overlay: None, + overlay: None, deferred_history_lines: Vec::new(), commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), @@ -117,7 +117,7 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { - if self.transcript_overlay.is_some() { + if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { match event { @@ -172,26 +172,27 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::InsertHistoryLines(lines) => { - if let Some(overlay) = &mut self.transcript_overlay { - overlay.insert_lines(lines.clone()); + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_lines(lines.clone()); tui.frame_requester().schedule_frame(); } self.transcript_lines.extend(lines.clone()); - if self.transcript_overlay.is_some() { + if self.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()); + let cell_transcript = cell.transcript_lines(); + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_lines(cell_transcript.clone()); tui.frame_requester().schedule_frame(); } - self.transcript_lines.extend(cell.transcript_lines()); + self.transcript_lines.extend(cell_transcript.clone()); let display = cell.display_lines(); if !display.is_empty() { - if self.transcript_overlay.is_some() { + if self.overlay.is_some() { self.deferred_history_lines.extend(display); } else { tui.insert_history_lines(display); @@ -240,7 +241,7 @@ impl App { } else { text.lines().map(ansi_escape_line).collect() }; - self.transcript_overlay = Some(TranscriptApp::with_title( + self.overlay = Some(Overlay::new_static_with_title( pager_lines, "D I F F".to_string(), )); @@ -284,7 +285,7 @@ impl App { } => { // Enter alternate screen and set viewport to full size. let _ = tui.enter_alt_screen(); - self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone())); + self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone())); tui.frame_requester().schedule_frame(); } // Esc primes/advances backtracking only in normal (not working) mode diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index fcab4a83..9c1251c3 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -1,6 +1,6 @@ use crate::app::App; use crate::backtrack_helpers; -use crate::transcript_app::TranscriptApp; +use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; use codex_core::protocol::ConversationHistoryResponseEvent; @@ -79,7 +79,7 @@ impl App { if self.chat_widget.composer_is_empty() { if !self.backtrack.primed { self.prime_backtrack(); - } else if self.transcript_overlay.is_none() { + } else if self.overlay.is_none() { self.open_backtrack_preview(tui); } else if self.backtrack.overlay_preview_active { self.step_backtrack_and_highlight(tui); @@ -103,7 +103,7 @@ impl App { /// Open transcript overlay (enters alternate screen and shows full transcript). pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) { let _ = tui.enter_alt_screen(); - self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone())); + self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone())); tui.frame_requester().schedule_frame(); } @@ -115,7 +115,7 @@ impl App { let lines = std::mem::take(&mut self.deferred_history_lines); tui.insert_history_lines(lines); } - self.transcript_overlay = None; + self.overlay = None; self.backtrack.overlay_preview_active = false; if was_backtrack { // Ensure backtrack state is fully reset when overlay closes (e.g. via 'q'). @@ -193,19 +193,19 @@ impl App { ) { let (nth, offset, hl) = selection; self.backtrack.count = nth; - if let Some(overlay) = &mut self.transcript_overlay { + if let Some(Overlay::Transcript(t)) = &mut self.overlay { if let Some(off) = offset { - overlay.scroll_offset = off; + t.set_scroll_offset(off); } - overlay.set_highlight_range(hl); + t.set_highlight_range(hl); } } /// Forward any event to the overlay and close it if done. fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - if let Some(overlay) = &mut self.transcript_overlay { + if let Some(overlay) = &mut self.overlay { overlay.handle_event(tui, event)?; - if overlay.is_done { + if overlay.is_done() { self.close_transcript_overlay(tui); tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cac5b8ac..e4352478 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -46,6 +46,7 @@ pub mod live_wrap; mod markdown; mod markdown_stream; pub mod onboarding; +mod pager_overlay; mod render; mod session_log; mod shimmer; @@ -53,7 +54,6 @@ 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/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs new file mode 100644 index 00000000..4c0cad2e --- /dev/null +++ b/codex-rs/tui/src/pager_overlay.rs @@ -0,0 +1,459 @@ +use std::io::Result; +use std::time::Duration; + +use crate::insert_history; +use crate::tui; +use crate::tui::TuiEvent; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +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; + +pub(crate) enum Overlay { + Transcript(TranscriptOverlay), + Static(StaticOverlay), +} + +impl Overlay { + pub(crate) fn new_transcript(lines: Vec>) -> Self { + Self::Transcript(TranscriptOverlay::new(lines)) + } + + pub(crate) fn new_static_with_title(lines: Vec>, title: String) -> Self { + Self::Static(StaticOverlay::with_title(lines, title)) + } + + pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match self { + Overlay::Transcript(o) => o.handle_event(tui, event), + Overlay::Static(o) => o.handle_event(tui, event), + } + } + + pub(crate) fn is_done(&self) -> bool { + match self { + Overlay::Transcript(o) => o.is_done(), + Overlay::Static(o) => o.is_done(), + } + } +} + +// Common pager navigation hints rendered on the first line +const PAGER_KEY_HINTS: &[(&str, &str)] = &[ + ("↑/↓", "scroll"), + ("PgUp/PgDn", "page"), + ("Home/End", "jump"), +]; + +// Render a single line of key hints from (key, description) pairs. +fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) { + let key_hint_style = Style::default().fg(Color::Cyan); + let mut spans: Vec> = vec![" ".into()]; + let mut first = true; + for (key, desc) in pairs { + if !first { + spans.push(" ".into()); + } + spans.push(Span::from(key.to_string()).set_style(key_hint_style)); + spans.push(" ".into()); + spans.push(Span::from(desc.to_string())); + first = false; + } + Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf); +} + +/// Generic widget for rendering a pager view. +struct PagerView { + lines: Vec>, + scroll_offset: usize, + title: String, +} + +impl PagerView { + fn new(lines: Vec>, title: String, scroll_offset: usize) -> Self { + Self { + lines, + scroll_offset, + title, + } + } + + fn render(&mut self, area: Rect, buf: &mut Buffer) { + self.render_header(area, buf); + let content_area = self.scroll_area(area); + let wrapped = insert_history::word_wrap_lines(&self.lines, content_area.width); + self.render_content_page(content_area, buf, &wrapped); + self.render_bottom_bar(area, content_area, buf, &wrapped); + } + + fn render_header(&self, area: Rect, buf: &mut Buffer) { + Span::from("/ ".repeat(area.width as usize / 2)) + .dim() + .render_ref(area, buf); + let header = format!("/ {}", self.title); + Span::from(header).dim().render_ref(area, buf); + } + + fn render_content_page(&mut self, area: Rect, buf: &mut Buffer, wrapped: &[Line<'static>]) { + self.scroll_offset = self + .scroll_offset + .min(wrapped.len().saturating_sub(area.height as usize)); + let start = self.scroll_offset; + let end = (start + area.height as usize).min(wrapped.len()); + let page = &wrapped[start..end]; + Paragraph::new(page.to_vec()).render_ref(area, buf); + + let visible = end.saturating_sub(start); + if visible < area.height as usize { + for i in 0..(area.height as usize - visible) { + let add = ((visible + i).min(u16::MAX as usize)) as u16; + let y = area.y.saturating_add(add); + Span::from("~") + .dim() + .render_ref(Rect::new(area.x, y, 1, 1), buf); + } + } + } + + fn render_bottom_bar( + &self, + full_area: Rect, + content_area: Rect, + buf: &mut Buffer, + wrapped: &[Line<'static>], + ) { + let sep_y = content_area.bottom(); + let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1); + + Span::from("─".repeat(sep_rect.width as usize)) + .dim() + .render_ref(sep_rect, buf); + let percent = if wrapped.is_empty() { + 100 + } else { + let max_scroll = wrapped.len().saturating_sub(content_area.height as usize); + 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); + } + + fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> { + match key_event { + 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 | KeyCode::Char(' '), + 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; + } + _ => { + return Ok(()); + } + } + tui.frame_requester() + .schedule_frame_in(Duration::from_millis(16)); + Ok(()) + } + + fn scroll_area(&self, area: Rect) -> Rect { + let mut area = area; + area.y = area.y.saturating_add(1); + area.height = area.height.saturating_sub(2); + area + } +} + +pub(crate) struct TranscriptOverlay { + view: PagerView, + highlight_range: Option<(usize, usize)>, + is_done: bool, +} + +impl TranscriptOverlay { + pub(crate) fn new(transcript_lines: Vec>) -> Self { + Self { + view: PagerView::new( + transcript_lines, + "T R A N S C R I P T".to_string(), + usize::MAX, + ), + highlight_range: None, + is_done: false, + } + } + + pub(crate) fn insert_lines(&mut self, lines: Vec>) { + self.view.lines.extend(lines); + } + + pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) { + self.highlight_range = range; + } + + fn render_hints(&self, area: Rect, buf: &mut Buffer) { + let line1 = Rect::new(area.x, area.y, area.width, 1); + let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); + render_key_hints(line1, buf, PAGER_KEY_HINTS); + let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", "edit prev")]; + if let Some((start, end)) = self.highlight_range + && end > start + { + pairs.push(("⏎", "edit message")); + } + render_key_hints(line2, buf, &pairs); + } + + pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { + let top_h = area.height.saturating_sub(3); + let top = Rect::new(area.x, area.y, area.width, top_h); + let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); + // Build highlighted lines into a temporary view for this render only + let mut lines = self.view.lines.clone(); + if let Some((start, end)) = self.highlight_range { + use ratatui::style::Modifier; + let len = lines.len(); + let start = start.min(len); + let end = end.min(len); + for (idx, line) in lines.iter_mut().enumerate().take(end).skip(start) { + let mut spans = Vec::with_capacity(line.spans.len()); + for (i, s) in line.spans.iter().enumerate() { + let mut style = s.style; + style.add_modifier |= Modifier::REVERSED; + if idx == start && i == 0 { + style.add_modifier |= Modifier::BOLD; + } + spans.push(Span { + style, + content: s.content.clone(), + }); + } + line.spans = spans; + } + } + let mut pv = PagerView::new(lines, self.view.title.clone(), self.view.scroll_offset); + pv.render(top, buf); + self.view.scroll_offset = pv.scroll_offset; + self.render_hints(bottom, buf); + } +} + +impl TranscriptOverlay { + pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match event { + TuiEvent::Key(key_event) => 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; + Ok(()) + } + other => self.view.handle_key_event(tui, other), + }, + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + self.render(frame.area(), frame.buffer); + })?; + Ok(()) + } + _ => Ok(()), + } + } + pub(crate) fn is_done(&self) -> bool { + self.is_done + } + pub(crate) fn set_scroll_offset(&mut self, offset: usize) { + self.view.scroll_offset = offset; + } +} + +pub(crate) struct StaticOverlay { + view: PagerView, + is_done: bool, +} + +impl StaticOverlay { + pub(crate) fn with_title(lines: Vec>, title: String) -> Self { + Self { + view: PagerView::new(lines, title, 0), + is_done: false, + } + } + + fn render_hints(&self, area: Rect, buf: &mut Buffer) { + let line1 = Rect::new(area.x, area.y, area.width, 1); + let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1); + render_key_hints(line1, buf, PAGER_KEY_HINTS); + let pairs = [("q", "quit")]; + render_key_hints(line2, buf, &pairs); + } + + pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) { + let top_h = area.height.saturating_sub(3); + let top = Rect::new(area.x, area.y, area.width, top_h); + let bottom = Rect::new(area.x, area.y + top_h, area.width, 3); + self.view.render(top, buf); + self.render_hints(bottom, buf); + } +} + +impl StaticOverlay { + pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match event { + TuiEvent::Key(key_event) => match key_event { + KeyEvent { + code: KeyCode::Char('q'), + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.is_done = true; + Ok(()) + } + other => self.view.handle_key_event(tui, other), + }, + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + self.render(frame.area(), frame.buffer); + })?; + Ok(()) + } + _ => Ok(()), + } + } + pub(crate) fn is_done(&self) -> bool { + self.is_done + } +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_snapshot; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + #[test] + fn edit_prev_hint_is_visible() { + let mut overlay = TranscriptOverlay::new(vec![Line::from("hello")]); + + // Render into a small buffer and assert the backtrack hint is present + let area = Rect::new(0, 0, 40, 10); + let mut buf = Buffer::empty(area); + overlay.render(area, &mut buf); + + // Flatten buffer to a string and check for the hint text + let mut s = String::new(); + for y in area.y..area.bottom() { + for x in area.x..area.right() { + s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); + } + s.push('\n'); + } + assert!( + s.contains("edit prev"), + "expected 'edit prev' hint in overlay footer, got: {s:?}" + ); + } + + #[test] + fn transcript_overlay_snapshot_basic() { + // Prepare a transcript overlay with a few lines + let mut overlay = TranscriptOverlay::new(vec![ + Line::from("alpha"), + Line::from("beta"), + Line::from("gamma"), + ]); + let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + term.draw(|f| overlay.render(f.area(), f.buffer_mut())) + .expect("draw"); + assert_snapshot!(term.backend()); + } + + #[test] + fn static_overlay_snapshot_basic() { + // Prepare a static overlay with a few lines and a title + let mut overlay = StaticOverlay::with_title( + vec![Line::from("one"), Line::from("two"), Line::from("three")], + "S T A T I C".to_string(), + ); + let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term"); + term.draw(|f| overlay.render(f.area(), f.buffer_mut())) + .expect("draw"); + assert_snapshot!(term.backend()); + } +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap new file mode 100644 index 00000000..ee65b04d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__static_overlay_snapshot_basic.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ S T A T I C / / / / / / / / / / / / / " +"one " +"two " +"three " +"~ " +"~ " +"───────────────────────────────── 100% ─" +" ↑/↓ scroll PgUp/PgDn page Home/End " +" q quit " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap new file mode 100644 index 00000000..b9105e80 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__pager_overlay__tests__transcript_overlay_snapshot_basic.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/pager_overlay.rs +expression: term.backend() +--- +"/ T R A N S C R I P T / / / / / / / / / " +"alpha " +"beta " +"gamma " +"~ " +"~ " +"───────────────────────────────── 100% ─" +" ↑/↓ scroll PgUp/PgDn page Home/End " +" q quit Esc edit prev " +" " diff --git a/codex-rs/tui/src/transcript_app.rs b/codex-rs/tui/src/transcript_app.rs deleted file mode 100644 index c2b73fe3..00000000 --- a/codex-rs/tui/src/transcript_app.rs +++ /dev/null @@ -1,350 +0,0 @@ -use std::io::Result; -use std::time::Duration; - -use crate::insert_history; -use crate::tui; -use crate::tui::TuiEvent; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use crossterm::event::KeyEventKind; -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; - -pub(crate) struct TranscriptApp { - pub(crate) transcript_lines: Vec>, - pub(crate) scroll_offset: usize, - pub(crate) is_done: bool, - title: String, - highlight_range: Option<(usize, usize)>, -} - -impl TranscriptApp { - pub(crate) fn new(transcript_lines: Vec>) -> Self { - Self { - transcript_lines, - scroll_offset: usize::MAX, - is_done: false, - title: "T R A N S C R I P T".to_string(), - highlight_range: None, - } - } - - pub(crate) fn with_title(transcript_lines: Vec>, title: String) -> Self { - Self { - transcript_lines, - scroll_offset: 0, - is_done: false, - title, - highlight_range: None, - } - } - pub(crate) fn insert_lines(&mut self, lines: Vec>) { - self.transcript_lines.extend(lines); - } - - /// Highlight the specified range [start, end) of transcript lines. - pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) { - self.highlight_range = range; - } - - 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(()) - } - - // set_backtrack_mode removed: overlay always shows backtrack guidance now. - - fn render(&mut self, area: Rect, buf: &mut Buffer) { - self.render_header(area, buf); - - // Main content area (excludes header and bottom status section) - let content_area = self.scroll_area(area); - let mut lines = self.transcript_lines.clone(); - self.apply_highlight_to_lines(&mut lines); - let wrapped = insert_history::word_wrap_lines(&lines, content_area.width); - - self.render_content_page(content_area, buf, &wrapped); - self.render_bottom_section(area, content_area, buf, &wrapped); - } - - // Private helpers - fn render_header(&self, area: Rect, buf: &mut Buffer) { - Span::from("/ ".repeat(area.width as usize / 2)) - .dim() - .render_ref(area, buf); - let header = format!("/ {}", self.title); - Span::from(header).dim().render_ref(area, buf); - } - - fn apply_highlight_to_lines(&self, lines: &mut [Line<'static>]) { - if let Some((start, end)) = self.highlight_range { - use ratatui::style::Modifier; - let len = lines.len(); - let start = start.min(len); - let end = end.min(len); - for (idx, line) in lines.iter_mut().enumerate().take(end).skip(start) { - let mut spans = Vec::with_capacity(line.spans.len()); - for (i, s) in line.spans.iter().enumerate() { - let mut style = s.style; - style.add_modifier |= Modifier::REVERSED; - if idx == start && i == 0 { - style.add_modifier |= Modifier::BOLD; - } - spans.push(Span { - style, - content: s.content.clone(), - }); - } - line.spans = spans; - } - } - } - - fn render_content_page(&mut self, area: Rect, buf: &mut Buffer, wrapped: &[Line<'static>]) { - // Clamp scroll offset to valid range - self.scroll_offset = self - .scroll_offset - .min(wrapped.len().saturating_sub(area.height as usize)); - let start = self.scroll_offset; - let end = (start + area.height as usize).min(wrapped.len()); - let page = &wrapped[start..end]; - Paragraph::new(page.to_vec()).render_ref(area, buf); - - // Fill remaining visible lines (if any) with a leading '~' in the first column. - let visible = (end - start) as u16; - if area.height > visible { - let extra = area.height - visible; - for i in 0..extra { - let y = area.y.saturating_add(visible + i); - Span::from("~") - .dim() - .render_ref(Rect::new(area.x, y, 1, 1), buf); - } - } - } - - /// Render the bottom status section (separator, percent scrolled, key hints). - fn render_bottom_section( - &self, - full_area: Rect, - content_area: Rect, - buf: &mut Buffer, - wrapped: &[Line<'static>], - ) { - let sep_y = content_area.bottom(); - let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1); - let hints_rect = Rect::new(full_area.x, sep_y + 1, full_area.width, 2); - - self.render_separator(buf, sep_rect); - let percent = self.compute_scroll_percent(wrapped.len(), content_area.height); - self.render_scroll_percentage(buf, sep_rect, percent); - self.render_hints(buf, hints_rect); - } - - /// Draw a dim horizontal separator line across the provided rect. - fn render_separator(&self, buf: &mut Buffer, sep_rect: Rect) { - Span::from("─".repeat(sep_rect.width as usize)) - .dim() - .render_ref(sep_rect, buf); - } - - /// Compute percent scrolled (0–100) based on wrapped length and content height. - fn compute_scroll_percent(&self, wrapped_len: usize, content_height: u16) -> u8 { - let max_scroll = wrapped_len.saturating_sub(content_height as usize); - if max_scroll == 0 { - 100 - } else { - (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() - as u8 - } - } - - /// Right-align and render the dim percent scrolled label on the separator line. - fn render_scroll_percentage(&self, buf: &mut Buffer, sep_rect: Rect, percent: 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); - } - - /// Render the dimmed key hints (scroll/page/jump and backtrack cue). - fn render_hints(&self, buf: &mut Buffer, hints_rect: Rect) { - 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 mut hints2 = vec![" ".into(), "q".set_style(key_hint_style), " quit".into()]; - hints2.extend([ - " ".into(), - "Esc".set_style(key_hint_style), - " edit prev".into(), - ]); - self.maybe_append_enter_edit_hint(&mut hints2, key_hint_style); - Paragraph::new(vec![Line::from(hints1).dim(), Line::from(hints2).dim()]) - .render_ref(hints_rect, buf); - } - - /// Conditionally append the "⏎ edit message" hint when a valid highlight is active. - fn maybe_append_enter_edit_hint(&self, hints: &mut Vec>, key_hint_style: Style) { - if let Some((start, end)) = self.highlight_range - && end > start - { - hints.extend([ - " ".into(), - "⏎".set_style(key_hint_style), - " edit message".into(), - ]); - } - } - - fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { - let mut defer_draw_ms: Option = None; - match key_event { - // Ctrl+Z is handled at the App level when transcript overlay is active - 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); - defer_draw_ms = Some(16); - } - KeyEvent { - code: KeyCode::Down, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.scroll_offset = self.scroll_offset.saturating_add(1); - defer_draw_ms = Some(16); - } - 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); - defer_draw_ms = Some(16); - } - KeyEvent { - code: KeyCode::PageDown | KeyCode::Char(' '), - 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); - defer_draw_ms = Some(16); - } - KeyEvent { - code: KeyCode::Home, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.scroll_offset = 0; - defer_draw_ms = Some(16); - } - KeyEvent { - code: KeyCode::End, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.scroll_offset = usize::MAX; - defer_draw_ms = Some(16); - } - _ => { - return; - } - } - if let Some(ms) = defer_draw_ms { - tui.frame_requester() - .schedule_frame_in(Duration::from_millis(ms)); - } else { - tui.frame_requester().schedule_frame(); - } - } - - 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 - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn edit_prev_hint_is_visible() { - let mut app = TranscriptApp::new(vec![Line::from("hello")]); - - // Render into a small buffer and assert the backtrack hint is present - let area = Rect::new(0, 0, 40, 10); - let mut buf = Buffer::empty(area); - app.render(area, &mut buf); - - // Flatten buffer to a string and check for the hint text - let mut s = String::new(); - for y in area.y..area.bottom() { - for x in area.x..area.right() { - s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' ')); - } - s.push('\n'); - } - assert!( - s.contains("edit prev"), - "expected 'edit prev' hint in overlay footer, got: {s:?}" - ); - } -}