fix transcript lines being added to diff view (#2721)

This fixes a bug where if you ran /diff while at turn was running,
transcript lines would be added to the end of the diff view. Also,
refactor to make this kind of issue less likely in future.
This commit is contained in:
Jeremy Rose
2025-08-26 17:03:11 -07:00
committed by GitHub
parent fb3f6456cf
commit 435154ce93
7 changed files with 512 additions and 374 deletions

View File

@@ -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<Line<'static>>,
// Transcript overlay state
pub(crate) transcript_overlay: Option<TranscriptApp>,
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
pub(crate) deferred_history_lines: Vec<Line<'static>>,
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<bool> {
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

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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<Line<'static>>) -> Self {
Self::Transcript(TranscriptOverlay::new(lines))
}
pub(crate) fn new_static_with_title(lines: Vec<Line<'static>>, 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<Span<'static>> = 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<Line<'static>>,
scroll_offset: usize,
title: String,
}
impl PagerView {
fn new(lines: Vec<Line<'static>>, 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<Line<'static>>) -> 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<Line<'static>>) {
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<Line<'static>>, 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());
}
}

View File

@@ -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 "
" "

View File

@@ -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 "
" "

View File

@@ -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<Line<'static>>,
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<Line<'static>>) -> 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<Line<'static>>, 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<Line<'static>>) {
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 (0100) 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<Span<'static>>, 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<u64> = 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:?}"
);
}
}