This shows the aggregated (stdout + stderr) buffer regardless of exit code. Many commands output useful / relevant info on stdout when returning a non-zero exit code, or the same on stderr when returning an exit code of 0. Often, useful info is present on both stdout AND stderr. Also, the model sees both. So it is confusing to see commands listed as "(no output)" that in fact do have output, just on the stream that doesn't match the exit status, or to see some sort of trivial output like "Tests failed" but lacking any information about the actual failure. As such, always display the aggregated output in the display. Transcript mode remains unchanged as it was already displaying the text that the model sees, which seems correct for transcript mode.
910 lines
30 KiB
Rust
910 lines
30 KiB
Rust
use std::io::Result;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::UserHistoryCell;
|
|
use crate::key_hint;
|
|
use crate::key_hint::KeyBinding;
|
|
use crate::render::Insets;
|
|
use crate::render::renderable::InsetRenderable;
|
|
use crate::render::renderable::Renderable;
|
|
use crate::style::user_message_style;
|
|
use crate::tui;
|
|
use crate::tui::TuiEvent;
|
|
use crossterm::event::KeyCode;
|
|
use crossterm::event::KeyEvent;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::buffer::Cell;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Style;
|
|
use ratatui::style::Stylize;
|
|
use ratatui::text::Line;
|
|
use ratatui::text::Span;
|
|
use ratatui::text::Text;
|
|
use ratatui::widgets::Clear;
|
|
use ratatui::widgets::Paragraph;
|
|
use ratatui::widgets::Widget;
|
|
use ratatui::widgets::WidgetRef;
|
|
use ratatui::widgets::Wrap;
|
|
|
|
pub(crate) enum Overlay {
|
|
Transcript(TranscriptOverlay),
|
|
Static(StaticOverlay),
|
|
}
|
|
|
|
impl Overlay {
|
|
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>) -> Self {
|
|
Self::Transcript(TranscriptOverlay::new(cells))
|
|
}
|
|
|
|
pub(crate) fn new_static_with_lines(lines: Vec<Line<'static>>, title: String) -> Self {
|
|
Self::Static(StaticOverlay::with_title(lines, title))
|
|
}
|
|
|
|
pub(crate) fn new_static_with_renderables(
|
|
renderables: Vec<Box<dyn Renderable>>,
|
|
title: String,
|
|
) -> Self {
|
|
Self::Static(StaticOverlay::with_renderables(renderables, 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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up);
|
|
const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down);
|
|
const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp);
|
|
const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown);
|
|
const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' '));
|
|
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
|
|
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
|
|
const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q'));
|
|
const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc);
|
|
const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter);
|
|
const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t'));
|
|
const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c'));
|
|
|
|
// Common pager navigation hints rendered on the first line
|
|
const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[
|
|
(&[KEY_UP, KEY_DOWN], "to scroll"),
|
|
(&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"),
|
|
(&[KEY_HOME, KEY_END], "to jump"),
|
|
];
|
|
|
|
// Render a single line of key hints from (key(s), description) pairs.
|
|
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) {
|
|
let mut spans: Vec<Span<'static>> = vec![" ".into()];
|
|
let mut first = true;
|
|
for (keys, desc) in pairs {
|
|
if !first {
|
|
spans.push(" ".into());
|
|
}
|
|
for (i, key) in keys.iter().enumerate() {
|
|
if i > 0 {
|
|
spans.push("/".into());
|
|
}
|
|
spans.push(Span::from(key));
|
|
}
|
|
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 {
|
|
renderables: Vec<Box<dyn Renderable>>,
|
|
scroll_offset: usize,
|
|
title: String,
|
|
last_content_height: Option<usize>,
|
|
last_rendered_height: Option<usize>,
|
|
/// If set, on next render ensure this chunk is visible.
|
|
pending_scroll_chunk: Option<usize>,
|
|
}
|
|
|
|
impl PagerView {
|
|
fn new(renderables: Vec<Box<dyn Renderable>>, title: String, scroll_offset: usize) -> Self {
|
|
Self {
|
|
renderables,
|
|
scroll_offset,
|
|
title,
|
|
last_content_height: None,
|
|
last_rendered_height: None,
|
|
pending_scroll_chunk: None,
|
|
}
|
|
}
|
|
|
|
fn content_height(&self, width: u16) -> usize {
|
|
self.renderables
|
|
.iter()
|
|
.map(|c| c.desired_height(width) as usize)
|
|
.sum()
|
|
}
|
|
|
|
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
|
Clear.render(area, buf);
|
|
self.render_header(area, buf);
|
|
let content_area = self.content_area(area);
|
|
self.update_last_content_height(content_area.height);
|
|
let content_height = self.content_height(content_area.width);
|
|
self.last_rendered_height = Some(content_height);
|
|
// If there is a pending request to scroll a specific chunk into view,
|
|
// satisfy it now that wrapping is up to date for this width.
|
|
if let Some(idx) = self.pending_scroll_chunk.take() {
|
|
self.ensure_chunk_visible(idx, content_area);
|
|
}
|
|
self.scroll_offset = self
|
|
.scroll_offset
|
|
.min(content_height.saturating_sub(content_area.height as usize));
|
|
|
|
self.render_content(content_area, buf);
|
|
|
|
self.render_bottom_bar(area, content_area, buf, content_height);
|
|
}
|
|
|
|
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);
|
|
header.dim().render_ref(area, buf);
|
|
}
|
|
|
|
fn render_content(&self, area: Rect, buf: &mut Buffer) {
|
|
let mut y = -(self.scroll_offset as isize);
|
|
let mut drawn_bottom = area.y;
|
|
for renderable in &self.renderables {
|
|
let top = y;
|
|
let height = renderable.desired_height(area.width) as isize;
|
|
y += height;
|
|
let bottom = y;
|
|
if bottom < area.y as isize {
|
|
continue;
|
|
}
|
|
if top > area.y as isize + area.height as isize {
|
|
break;
|
|
}
|
|
if top < 0 {
|
|
let drawn = render_offset_content(area, buf, &**renderable, (-top) as u16);
|
|
drawn_bottom = drawn_bottom.max(area.y + drawn);
|
|
} else {
|
|
let draw_height = (height as u16).min(area.height.saturating_sub(top as u16));
|
|
let draw_area = Rect::new(area.x, area.y + top as u16, area.width, draw_height);
|
|
renderable.render(draw_area, buf);
|
|
drawn_bottom = drawn_bottom.max(draw_area.y.saturating_add(draw_area.height));
|
|
}
|
|
}
|
|
|
|
for y in drawn_bottom..area.bottom() {
|
|
if area.width == 0 {
|
|
break;
|
|
}
|
|
buf[(area.x, y)] = Cell::from('~');
|
|
for x in area.x + 1..area.right() {
|
|
buf[(x, y)] = Cell::from(' ');
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_bottom_bar(
|
|
&self,
|
|
full_area: Rect,
|
|
content_area: Rect,
|
|
buf: &mut Buffer,
|
|
total_len: usize,
|
|
) {
|
|
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 total_len == 0 {
|
|
100
|
|
} else {
|
|
let max_scroll = total_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 {
|
|
e if KEY_UP.is_press(e) => {
|
|
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
|
}
|
|
e if KEY_DOWN.is_press(e) => {
|
|
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
|
}
|
|
e if KEY_PAGE_UP.is_press(e) => {
|
|
let area = self.content_area(tui.terminal.viewport_area);
|
|
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
|
|
}
|
|
e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) => {
|
|
let area = self.content_area(tui.terminal.viewport_area);
|
|
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
|
|
}
|
|
e if KEY_HOME.is_press(e) => {
|
|
self.scroll_offset = 0;
|
|
}
|
|
e if KEY_END.is_press(e) => {
|
|
self.scroll_offset = usize::MAX;
|
|
}
|
|
_ => {
|
|
return Ok(());
|
|
}
|
|
}
|
|
tui.frame_requester()
|
|
.schedule_frame_in(Duration::from_millis(16));
|
|
Ok(())
|
|
}
|
|
|
|
fn update_last_content_height(&mut self, height: u16) {
|
|
self.last_content_height = Some(height as usize);
|
|
}
|
|
|
|
fn content_area(&self, area: Rect) -> Rect {
|
|
let mut area = area;
|
|
area.y = area.y.saturating_add(1);
|
|
area.height = area.height.saturating_sub(2);
|
|
area
|
|
}
|
|
}
|
|
|
|
impl PagerView {
|
|
fn is_scrolled_to_bottom(&self) -> bool {
|
|
if self.scroll_offset == usize::MAX {
|
|
return true;
|
|
}
|
|
let Some(height) = self.last_content_height else {
|
|
return false;
|
|
};
|
|
if self.renderables.is_empty() {
|
|
return true;
|
|
}
|
|
let Some(total_height) = self.last_rendered_height else {
|
|
return false;
|
|
};
|
|
if total_height <= height {
|
|
return true;
|
|
}
|
|
let max_scroll = total_height.saturating_sub(height);
|
|
self.scroll_offset >= max_scroll
|
|
}
|
|
|
|
/// Request that the given text chunk index be scrolled into view on next render.
|
|
fn scroll_chunk_into_view(&mut self, chunk_index: usize) {
|
|
self.pending_scroll_chunk = Some(chunk_index);
|
|
}
|
|
|
|
fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
|
|
if area.height == 0 || idx >= self.renderables.len() {
|
|
return;
|
|
}
|
|
let first = self
|
|
.renderables
|
|
.iter()
|
|
.take(idx)
|
|
.map(|r| r.desired_height(area.width) as usize)
|
|
.sum();
|
|
let last = first + self.renderables[idx].desired_height(area.width) as usize;
|
|
let current_top = self.scroll_offset;
|
|
let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize);
|
|
if first < current_top {
|
|
self.scroll_offset = first;
|
|
} else if last > current_bottom {
|
|
self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A renderable that caches its desired height.
|
|
struct CachedRenderable {
|
|
renderable: Box<dyn Renderable>,
|
|
height: std::cell::Cell<Option<u16>>,
|
|
last_width: std::cell::Cell<Option<u16>>,
|
|
}
|
|
|
|
impl CachedRenderable {
|
|
fn new(renderable: impl Into<Box<dyn Renderable>>) -> Self {
|
|
Self {
|
|
renderable: renderable.into(),
|
|
height: std::cell::Cell::new(None),
|
|
last_width: std::cell::Cell::new(None),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Renderable for CachedRenderable {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
self.renderable.render(area, buf);
|
|
}
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
if self.last_width.get() != Some(width) {
|
|
let height = self.renderable.desired_height(width);
|
|
self.height.set(Some(height));
|
|
self.last_width.set(Some(width));
|
|
}
|
|
self.height.get().unwrap_or(0)
|
|
}
|
|
}
|
|
|
|
struct CellRenderable {
|
|
cell: Arc<dyn HistoryCell>,
|
|
style: Style,
|
|
}
|
|
|
|
impl Renderable for CellRenderable {
|
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
let p =
|
|
Paragraph::new(Text::from(self.cell.transcript_lines(area.width))).style(self.style);
|
|
p.render(area, buf);
|
|
}
|
|
|
|
fn desired_height(&self, width: u16) -> u16 {
|
|
self.cell.desired_transcript_height(width)
|
|
}
|
|
}
|
|
|
|
pub(crate) struct TranscriptOverlay {
|
|
view: PagerView,
|
|
cells: Vec<Arc<dyn HistoryCell>>,
|
|
highlight_cell: Option<usize>,
|
|
is_done: bool,
|
|
}
|
|
|
|
impl TranscriptOverlay {
|
|
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
|
|
Self {
|
|
view: PagerView::new(
|
|
Self::render_cells(&transcript_cells, None),
|
|
"T R A N S C R I P T".to_string(),
|
|
usize::MAX,
|
|
),
|
|
cells: transcript_cells,
|
|
highlight_cell: None,
|
|
is_done: false,
|
|
}
|
|
}
|
|
|
|
fn render_cells(
|
|
cells: &[Arc<dyn HistoryCell>],
|
|
highlight_cell: Option<usize>,
|
|
) -> Vec<Box<dyn Renderable>> {
|
|
cells
|
|
.iter()
|
|
.enumerate()
|
|
.flat_map(|(i, c)| {
|
|
let mut v: Vec<Box<dyn Renderable>> = Vec::new();
|
|
let mut cell_renderable = if c.as_any().is::<UserHistoryCell>() {
|
|
Box::new(CachedRenderable::new(CellRenderable {
|
|
cell: c.clone(),
|
|
style: if highlight_cell == Some(i) {
|
|
user_message_style().reversed()
|
|
} else {
|
|
user_message_style()
|
|
},
|
|
})) as Box<dyn Renderable>
|
|
} else {
|
|
Box::new(CachedRenderable::new(CellRenderable {
|
|
cell: c.clone(),
|
|
style: Style::default(),
|
|
})) as Box<dyn Renderable>
|
|
};
|
|
if !c.is_stream_continuation() && i > 0 {
|
|
cell_renderable = Box::new(InsetRenderable::new(
|
|
cell_renderable,
|
|
Insets::tlbr(1, 0, 0, 0),
|
|
));
|
|
}
|
|
v.push(cell_renderable);
|
|
v
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
|
|
let follow_bottom = self.view.is_scrolled_to_bottom();
|
|
self.cells.push(cell);
|
|
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
|
|
if follow_bottom {
|
|
self.view.scroll_offset = usize::MAX;
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
|
|
self.highlight_cell = cell;
|
|
self.view.renderables = Self::render_cells(&self.cells, self.highlight_cell);
|
|
if let Some(idx) = self.highlight_cell {
|
|
self.view.scroll_chunk_into_view(idx);
|
|
}
|
|
}
|
|
|
|
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<(&[KeyBinding], &str)> =
|
|
vec![(&[KEY_Q], "to quit"), (&[KEY_ESC], "to edit prev")];
|
|
if self.highlight_cell.is_some() {
|
|
pairs.push((&[KEY_ENTER], "to 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);
|
|
self.view.render(top, buf);
|
|
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 {
|
|
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => {
|
|
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) struct StaticOverlay {
|
|
view: PagerView,
|
|
is_done: bool,
|
|
}
|
|
|
|
impl StaticOverlay {
|
|
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
|
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
|
|
Self::with_renderables(vec![Box::new(CachedRenderable::new(paragraph))], title)
|
|
}
|
|
|
|
pub(crate) fn with_renderables(renderables: Vec<Box<dyn Renderable>>, title: String) -> Self {
|
|
Self {
|
|
view: PagerView::new(renderables, 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: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to 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 {
|
|
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => {
|
|
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
|
|
}
|
|
}
|
|
|
|
fn render_offset_content(
|
|
area: Rect,
|
|
buf: &mut Buffer,
|
|
renderable: &dyn Renderable,
|
|
scroll_offset: u16,
|
|
) -> u16 {
|
|
let height = renderable.desired_height(area.width);
|
|
let mut tall_buf = Buffer::empty(Rect::new(
|
|
0,
|
|
0,
|
|
area.width,
|
|
height.min(area.height + scroll_offset),
|
|
));
|
|
renderable.render(*tall_buf.area(), &mut tall_buf);
|
|
let copy_height = area
|
|
.height
|
|
.min(tall_buf.area().height.saturating_sub(scroll_offset));
|
|
for y in 0..copy_height {
|
|
let src_y = y + scroll_offset;
|
|
for x in 0..area.width {
|
|
buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone();
|
|
}
|
|
}
|
|
|
|
copy_height
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codex_core::protocol::ReviewDecision;
|
|
use insta::assert_snapshot;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use crate::exec_cell::CommandOutput;
|
|
use crate::history_cell;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::new_patch_event;
|
|
use codex_core::protocol::FileChange;
|
|
use codex_protocol::parse_command::ParsedCommand;
|
|
use ratatui::Terminal;
|
|
use ratatui::backend::TestBackend;
|
|
use ratatui::text::Text;
|
|
|
|
#[derive(Debug)]
|
|
struct TestCell {
|
|
lines: Vec<Line<'static>>,
|
|
}
|
|
|
|
impl crate::history_cell::HistoryCell for TestCell {
|
|
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
|
self.lines.clone()
|
|
}
|
|
|
|
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
|
self.lines.clone()
|
|
}
|
|
}
|
|
|
|
fn paragraph_block(label: &str, lines: usize) -> Box<dyn Renderable> {
|
|
let text = Text::from(
|
|
(0..lines)
|
|
.map(|i| Line::from(format!("{label}{i}")))
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
Box::new(Paragraph::new(text)) as Box<dyn Renderable>
|
|
}
|
|
|
|
#[test]
|
|
fn edit_prev_hint_is_visible() {
|
|
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
|
lines: 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![
|
|
Arc::new(TestCell {
|
|
lines: vec![Line::from("alpha")],
|
|
}),
|
|
Arc::new(TestCell {
|
|
lines: vec![Line::from("beta")],
|
|
}),
|
|
Arc::new(TestCell {
|
|
lines: vec![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());
|
|
}
|
|
|
|
fn buffer_to_text(buf: &Buffer, area: Rect) -> String {
|
|
let mut out = String::new();
|
|
for y in area.y..area.bottom() {
|
|
for x in area.x..area.right() {
|
|
let symbol = buf[(x, y)].symbol();
|
|
if symbol.is_empty() {
|
|
out.push(' ');
|
|
} else {
|
|
out.push(symbol.chars().next().unwrap_or(' '));
|
|
}
|
|
}
|
|
// Trim trailing spaces for stability.
|
|
while out.ends_with(' ') {
|
|
out.pop();
|
|
}
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn transcript_overlay_apply_patch_scroll_vt100_clears_previous_page() {
|
|
let cwd = PathBuf::from("/repo");
|
|
let mut cells: Vec<Arc<dyn HistoryCell>> = Vec::new();
|
|
|
|
let mut approval_changes = HashMap::new();
|
|
approval_changes.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\nworld\n".to_string(),
|
|
},
|
|
);
|
|
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(approval_changes, &cwd));
|
|
cells.push(approval_cell);
|
|
|
|
let mut apply_changes = HashMap::new();
|
|
apply_changes.insert(
|
|
PathBuf::from("foo.txt"),
|
|
FileChange::Add {
|
|
content: "hello\nworld\n".to_string(),
|
|
},
|
|
);
|
|
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(apply_changes, &cwd));
|
|
cells.push(apply_begin_cell);
|
|
|
|
let apply_end_cell: Arc<dyn HistoryCell> =
|
|
history_cell::new_approval_decision_cell(vec!["ls".into()], ReviewDecision::Approved)
|
|
.into();
|
|
cells.push(apply_end_cell);
|
|
|
|
let mut exec_cell = crate::exec_cell::new_active_exec_command(
|
|
"exec-1".into(),
|
|
vec!["bash".into(), "-lc".into(), "ls".into()],
|
|
vec![ParsedCommand::Unknown { cmd: "ls".into() }],
|
|
);
|
|
exec_cell.complete_call(
|
|
"exec-1",
|
|
CommandOutput {
|
|
exit_code: 0,
|
|
aggregated_output: "src\nREADME.md\n".into(),
|
|
formatted_output: "src\nREADME.md\n".into(),
|
|
},
|
|
Duration::from_millis(420),
|
|
);
|
|
let exec_cell: Arc<dyn HistoryCell> = Arc::new(exec_cell);
|
|
cells.push(exec_cell);
|
|
|
|
let mut overlay = TranscriptOverlay::new(cells);
|
|
let area = Rect::new(0, 0, 80, 12);
|
|
let mut buf = Buffer::empty(area);
|
|
|
|
overlay.render(area, &mut buf);
|
|
overlay.view.scroll_offset = 0;
|
|
overlay.render(area, &mut buf);
|
|
|
|
let snapshot = buffer_to_text(&buf, area);
|
|
assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot);
|
|
}
|
|
|
|
#[test]
|
|
fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
|
|
let mut overlay = TranscriptOverlay::new(
|
|
(0..20)
|
|
.map(|i| {
|
|
Arc::new(TestCell {
|
|
lines: vec![Line::from(format!("line{i}"))],
|
|
}) as Arc<dyn HistoryCell>
|
|
})
|
|
.collect(),
|
|
);
|
|
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
|
|
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
|
.expect("draw");
|
|
|
|
assert!(
|
|
overlay.view.is_scrolled_to_bottom(),
|
|
"expected initial render to leave view at bottom"
|
|
);
|
|
|
|
overlay.insert_cell(Arc::new(TestCell {
|
|
lines: vec!["tail".into()],
|
|
}));
|
|
|
|
assert_eq!(overlay.view.scroll_offset, usize::MAX);
|
|
}
|
|
|
|
#[test]
|
|
fn transcript_overlay_preserves_manual_scroll_position() {
|
|
let mut overlay = TranscriptOverlay::new(
|
|
(0..20)
|
|
.map(|i| {
|
|
Arc::new(TestCell {
|
|
lines: vec![Line::from(format!("line{i}"))],
|
|
}) as Arc<dyn HistoryCell>
|
|
})
|
|
.collect(),
|
|
);
|
|
let mut term = Terminal::new(TestBackend::new(40, 12)).expect("term");
|
|
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
|
.expect("draw");
|
|
|
|
overlay.view.scroll_offset = 0;
|
|
|
|
overlay.insert_cell(Arc::new(TestCell {
|
|
lines: vec!["tail".into()],
|
|
}));
|
|
|
|
assert_eq!(overlay.view.scroll_offset, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn static_overlay_snapshot_basic() {
|
|
// Prepare a static overlay with a few lines and a title
|
|
let mut overlay = StaticOverlay::with_title(
|
|
vec!["one".into(), "two".into(), "three".into()],
|
|
"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());
|
|
}
|
|
|
|
#[test]
|
|
fn static_overlay_wraps_long_lines() {
|
|
let mut overlay = StaticOverlay::with_title(
|
|
vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()],
|
|
"S T A T I C".to_string(),
|
|
);
|
|
let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term");
|
|
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
|
|
.expect("draw");
|
|
assert_snapshot!(term.backend());
|
|
}
|
|
|
|
#[test]
|
|
fn pager_view_content_height_counts_renderables() {
|
|
let pv = PagerView::new(
|
|
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
|
|
"T".to_string(),
|
|
0,
|
|
);
|
|
|
|
assert_eq!(pv.content_height(80), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
|
|
let mut pv = PagerView::new(
|
|
vec![
|
|
paragraph_block("a", 1),
|
|
paragraph_block("b", 3),
|
|
paragraph_block("c", 3),
|
|
],
|
|
"T".to_string(),
|
|
0,
|
|
);
|
|
let area = Rect::new(0, 0, 20, 8);
|
|
|
|
pv.scroll_offset = 0;
|
|
let content_area = pv.content_area(area);
|
|
pv.ensure_chunk_visible(2, content_area);
|
|
|
|
let mut buf = Buffer::empty(area);
|
|
pv.render(area, &mut buf);
|
|
let rendered = buffer_to_text(&buf, area);
|
|
|
|
assert!(
|
|
rendered.contains("c0"),
|
|
"expected chunk top in view: {rendered:?}"
|
|
);
|
|
assert!(
|
|
rendered.contains("c1"),
|
|
"expected chunk middle in view: {rendered:?}"
|
|
);
|
|
assert!(
|
|
rendered.contains("c2"),
|
|
"expected chunk bottom in view: {rendered:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
|
|
let mut pv = PagerView::new(
|
|
vec![
|
|
paragraph_block("a", 2),
|
|
paragraph_block("b", 3),
|
|
paragraph_block("c", 3),
|
|
],
|
|
"T".to_string(),
|
|
0,
|
|
);
|
|
let area = Rect::new(0, 0, 20, 3);
|
|
|
|
pv.scroll_offset = 6;
|
|
pv.ensure_chunk_visible(0, area);
|
|
|
|
assert_eq!(pv.scroll_offset, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
|
|
let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 0);
|
|
let area = Rect::new(0, 0, 20, 8);
|
|
let mut buf = Buffer::empty(area);
|
|
|
|
pv.render(area, &mut buf);
|
|
|
|
assert!(
|
|
!pv.is_scrolled_to_bottom(),
|
|
"expected view to report not at bottom when offset < max"
|
|
);
|
|
|
|
pv.scroll_offset = usize::MAX;
|
|
pv.render(area, &mut buf);
|
|
|
|
assert!(
|
|
pv.is_scrolled_to_bottom(),
|
|
"expected view to report at bottom after scrolling to end"
|
|
);
|
|
}
|
|
}
|