rework patch/exec approval UI (#4573)

| Scenario | Screenshot |
| ---------------------- |
----------------------------------------------------------------------------------------------------------------------------------------------------
|
| short patch | <img width="1096" height="533" alt="short patch"
src="https://github.com/user-attachments/assets/8a883429-0965-4c0b-9002-217b3759b557"
/> |
| short command | <img width="1096" height="533" alt="short command"
src="https://github.com/user-attachments/assets/901abde8-2494-4e86-b98a-7cabaf87ca9c"
/> |
| long patch | <img width="1129" height="892" alt="long patch"
src="https://github.com/user-attachments/assets/fa799a29-a0d6-48e6-b2ef-10302a7916d3"
/> |
| long command | <img width="1096" height="892" alt="long command"
src="https://github.com/user-attachments/assets/11ddf79b-98cb-4b60-ac22-49dfa7779343"
/> |
| viewing complete patch | <img width="1129" height="892" alt="viewing
complete patch"
src="https://github.com/user-attachments/assets/81666958-af94-420e-aa66-b60d0a42b9db"
/> |
This commit is contained in:
Jeremy Rose
2025-10-01 14:29:05 -07:00
committed by GitHub
parent 31102af54b
commit 07c1db351a
30 changed files with 1127 additions and 1141 deletions

View File

@@ -3,13 +3,14 @@ use std::sync::Arc;
use std::time::Duration;
use crate::history_cell::HistoryCell;
use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
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::buffer::Cell;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
@@ -22,6 +23,7 @@ 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),
@@ -33,10 +35,17 @@ impl Overlay {
Self::Transcript(TranscriptOverlay::new(cells))
}
pub(crate) fn new_static_with_title(lines: Vec<Line<'static>>, title: String) -> Self {
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),
@@ -78,57 +87,53 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
/// Generic widget for rendering a pager view.
struct PagerView {
texts: Vec<Text<'static>>,
renderables: Vec<Box<dyn Renderable>>,
scroll_offset: usize,
title: String,
wrap_cache: Option<WrapCache>,
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(texts: Vec<Text<'static>>, title: String, scroll_offset: usize) -> Self {
fn new(renderables: Vec<Box<dyn Renderable>>, title: String, scroll_offset: usize) -> Self {
Self {
texts,
renderables,
scroll_offset,
title,
wrap_cache: None,
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.scroll_area(area);
let content_area = self.content_area(area);
self.update_last_content_height(content_area.height);
self.ensure_wrapped(content_area.width);
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), Some(cache)) =
(self.pending_scroll_chunk.take(), self.wrap_cache.as_ref())
&& let Some(range) = cache.chunk_ranges.get(idx).cloned()
{
self.ensure_range_visible(range, content_area.height as usize, cache.wrapped.len());
if let Some(idx) = self.pending_scroll_chunk.take() {
self.ensure_chunk_visible(idx, content_area);
}
// Compute page bounds without holding an immutable borrow on cache while mutating self
let wrapped_len = self
.wrap_cache
.as_ref()
.map(|c| c.wrapped.len())
.unwrap_or(0);
self.scroll_offset = self
.scroll_offset
.min(wrapped_len.saturating_sub(content_area.height as usize));
let start = self.scroll_offset;
let end = (start + content_area.height as usize).min(wrapped_len);
.min(content_height.saturating_sub(content_area.height as usize));
let wrapped = self.cached();
let page = &wrapped[start..end];
self.render_content_page_prepared(content_area, buf, page);
self.render_bottom_bar(area, content_area, buf, wrapped);
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) {
@@ -139,20 +144,38 @@ impl PagerView {
header.dim().render_ref(area, buf);
}
// Removed unused render_content_page (replaced by render_content_page_prepared)
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));
}
}
fn render_content_page_prepared(&self, area: Rect, buf: &mut Buffer, page: &[Line<'static>]) {
Clear.render(area, buf);
Paragraph::new(page.to_vec()).render_ref(area, buf);
let visible = page.len();
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);
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(' ');
}
}
}
@@ -162,7 +185,7 @@ impl PagerView {
full_area: Rect,
content_area: Rect,
buf: &mut Buffer,
wrapped: &[Line<'static>],
total_len: usize,
) {
let sep_y = content_area.bottom();
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
@@ -170,10 +193,10 @@ impl PagerView {
Span::from("".repeat(sep_rect.width as usize))
.dim()
.render_ref(sep_rect, buf);
let percent = if wrapped.is_empty() {
let percent = if total_len == 0 {
100
} else {
let max_scroll = wrapped.len().saturating_sub(content_area.height as usize);
let max_scroll = total_len.saturating_sub(content_area.height as usize);
if max_scroll == 0 {
100
} else {
@@ -210,7 +233,7 @@ impl PagerView {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.scroll_area(tui.terminal.viewport_area);
let area = self.content_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
}
KeyEvent {
@@ -218,7 +241,7 @@ impl PagerView {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.scroll_area(tui.terminal.viewport_area);
let area = self.content_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
}
KeyEvent {
@@ -248,7 +271,7 @@ impl PagerView {
self.last_content_height = Some(height as usize);
}
fn scroll_area(&self, area: Rect) -> Rect {
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);
@@ -256,67 +279,24 @@ impl PagerView {
}
}
#[derive(Debug, Clone)]
struct WrapCache {
width: u16,
wrapped: Vec<Line<'static>>,
/// For each input Text chunk, the inclusive-excluded range of wrapped lines produced.
chunk_ranges: Vec<std::ops::Range<usize>>,
base_len: usize,
}
impl PagerView {
fn ensure_wrapped(&mut self, width: u16) {
let width = width.max(1);
let needs = match self.wrap_cache {
Some(ref c) => c.width != width || c.base_len != self.texts.len(),
None => true,
};
if !needs {
return;
}
let mut wrapped: Vec<Line<'static>> = Vec::new();
let mut chunk_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(self.texts.len());
for text in &self.texts {
let start = wrapped.len();
for line in &text.lines {
let ws = crate::wrapping::word_wrap_line(line, width as usize);
push_owned_lines(&ws, &mut wrapped);
}
let end = wrapped.len();
chunk_ranges.push(start..end);
}
self.wrap_cache = Some(WrapCache {
width,
wrapped,
chunk_ranges,
base_len: self.texts.len(),
});
}
fn cached(&self) -> &[Line<'static>] {
if let Some(cache) = self.wrap_cache.as_ref() {
&cache.wrapped
} else {
&[]
}
}
fn is_scrolled_to_bottom(&self) -> bool {
if self.scroll_offset == usize::MAX {
return true;
}
let Some(cache) = &self.wrap_cache else {
return false;
};
let Some(height) = self.last_content_height else {
return false;
};
if cache.wrapped.is_empty() {
if self.renderables.is_empty() {
return true;
}
let visible = height.min(cache.wrapped.len());
let max_scroll = cache.wrapped.len().saturating_sub(visible);
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
}
@@ -325,32 +305,57 @@ impl PagerView {
self.pending_scroll_chunk = Some(chunk_index);
}
fn ensure_range_visible(
&mut self,
range: std::ops::Range<usize>,
viewport_height: usize,
total_wrapped: usize,
) {
if viewport_height == 0 || total_wrapped == 0 {
fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
if area.height == 0 || idx >= self.renderables.len() {
return;
}
let first = range.start.min(total_wrapped.saturating_sub(1));
let last = range
.end
.saturating_sub(1)
.min(total_wrapped.saturating_sub(1));
let current_top = self.scroll_offset.min(total_wrapped.saturating_sub(1));
let current_bottom = current_top.saturating_add(viewport_height.saturating_sub(1));
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 {
// Scroll just enough so that 'last' is visible at the bottom
self.scroll_offset = last.saturating_sub(viewport_height.saturating_sub(1));
self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
}
}
}
struct CachedParagraph {
paragraph: Paragraph<'static>,
height: std::cell::Cell<Option<u16>>,
last_width: std::cell::Cell<Option<u16>>,
}
impl CachedParagraph {
fn new(paragraph: Paragraph<'static>) -> Self {
Self {
paragraph,
height: std::cell::Cell::new(None),
last_width: std::cell::Cell::new(None),
}
}
}
impl Renderable for CachedParagraph {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.paragraph.render_ref(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
if self.last_width.get() != Some(width) {
let height = self.paragraph.line_count(width) as u16;
self.height.set(Some(height));
self.last_width.set(Some(width));
}
self.height.get().unwrap_or(0)
}
}
pub(crate) struct TranscriptOverlay {
view: PagerView,
cells: Vec<Arc<dyn HistoryCell>>,
@@ -375,8 +380,8 @@ impl TranscriptOverlay {
fn render_cells_to_texts(
cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>,
) -> Vec<Text<'static>> {
let mut texts: Vec<Text<'static>> = Vec::new();
) -> Vec<Box<dyn Renderable>> {
let mut texts: Vec<Box<dyn Renderable>> = Vec::new();
let mut first = true;
for (idx, cell) in cells.iter().enumerate() {
let mut lines: Vec<Line<'static>> = Vec::new();
@@ -392,7 +397,9 @@ impl TranscriptOverlay {
cell.transcript_lines()
};
lines.extend(cell_lines);
texts.push(Text::from(lines));
texts.push(Box::new(CachedParagraph::new(
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
)));
first = false;
}
texts
@@ -406,9 +413,10 @@ impl TranscriptOverlay {
lines.push(Line::from(""));
}
lines.extend(cell.transcript_lines());
self.view.texts.push(Text::from(lines));
self.view.renderables.push(Box::new(CachedParagraph::new(
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
)));
self.cells.push(cell);
self.view.wrap_cache = None;
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
@@ -416,8 +424,7 @@ impl TranscriptOverlay {
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell;
self.view.wrap_cache = None;
self.view.texts = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
}
@@ -490,8 +497,17 @@ pub(crate) struct StaticOverlay {
impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
Self::with_renderables(
vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from(
lines,
))))],
title,
)
}
pub(crate) fn with_renderables(renderables: Vec<Box<dyn Renderable>>, title: String) -> Self {
Self {
view: PagerView::new(vec![Text::from(lines)], title, 0),
view: PagerView::new(renderables, title, 0),
is_done: false,
}
}
@@ -547,6 +563,33 @@ impl StaticOverlay {
}
}
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::*;
@@ -558,12 +601,12 @@ mod tests {
use crate::exec_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
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 {
@@ -580,6 +623,15 @@ mod tests {
}
}
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 {
@@ -657,11 +709,7 @@ mod tests {
content: "hello\nworld\n".to_string(),
},
);
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(
PatchEventType::ApprovalRequest,
approval_changes,
&cwd,
));
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(approval_changes, &cwd));
cells.push(approval_cell);
let mut apply_changes = HashMap::new();
@@ -671,13 +719,7 @@ mod tests {
content: "hello\nworld\n".to_string(),
},
);
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(
PatchEventType::ApplyBegin {
auto_approved: false,
},
apply_changes,
&cwd,
));
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> =
@@ -711,7 +753,6 @@ mod tests {
overlay.render(area, &mut buf);
overlay.view.scroll_offset = 0;
overlay.view.wrap_cache = None;
overlay.render(area, &mut buf);
let snapshot = buffer_to_text(&buf, area);
@@ -783,54 +824,89 @@ mod tests {
}
#[test]
fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() {
let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output.";
let mut pv = PagerView::new(
vec![Text::from(vec![long.into()]), Text::from(vec![long.into()])],
fn pager_view_content_height_counts_renderables() {
let pv = PagerView::new(
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
"T".to_string(),
0,
);
// Build cache at width 24
pv.ensure_wrapped(24);
let w1 = pv.cached();
assert!(!w1.is_empty(), "expected wrapped output to be non-empty");
let ptr1 = w1.as_ptr();
assert_eq!(pv.content_height(80), 5);
}
// Re-run with same width: cache should be reused (pointer stability heuristic)
pv.ensure_wrapped(24);
let w2 = pv.cached();
let ptr2 = w2.as_ptr();
assert_eq!(ptr1, ptr2, "cache should not rebuild for unchanged width");
#[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);
// Change width: cache should rebuild and likely produce different length
// Drop immutable borrow before mutating
let prev_len = w2.len();
pv.ensure_wrapped(36);
let w3 = pv.cached();
assert_ne!(
prev_len,
w3.len(),
"wrapped length should change on width change"
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_wrap_cache_invalidates_on_append() {
let long = "Another long line for wrapping behavior verification.";
let mut pv = PagerView::new(vec![Text::from(vec![long.into()])], "T".to_string(), 0);
pv.ensure_wrapped(28);
let w1 = pv.cached();
let len1 = w1.len();
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);
// Append new lines should cause ensure_wrapped to rebuild due to len change
pv.texts.push(Text::from(vec![long.into()]));
pv.texts.push(Text::from(vec![long.into()]));
pv.ensure_wrapped(28);
let w2 = pv.cached();
assert!(
w2.len() >= len1,
"wrapped length should grow or stay same after append"
!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"
);
}
}