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:
459
codex-rs/tui/src/pager_overlay.rs
Normal file
459
codex-rs/tui/src/pager_overlay.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user