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

@@ -1,10 +1,14 @@
use crate::app_backtrack::BacktrackState; use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidget;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::file_search::FileSearchManager; use crate::file_search::FileSearchManager;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use crate::pager_overlay::Overlay; use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::resume_picker::ResumeSelection; use crate::resume_picker::ResumeSelection;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent; use crate::tui::TuiEvent;
@@ -292,7 +296,7 @@ impl App {
} else { } else {
text.lines().map(ansi_escape_line).collect() text.lines().map(ansi_escape_line).collect()
}; };
self.overlay = Some(Overlay::new_static_with_title( self.overlay = Some(Overlay::new_static_with_lines(
pager_lines, pager_lines,
"D I F F".to_string(), "D I F F".to_string(),
)); ));
@@ -324,12 +328,18 @@ impl App {
Ok(()) => { Ok(()) => {
if let Some(profile) = profile { if let Some(profile) = profile {
self.chat_widget.add_info_message( self.chat_widget.add_info_message(
format!("Model changed to {model} for {profile} profile"), format!("Model changed to {model}{reasoning_effort} for {profile} profile", reasoning_effort = effort.map(|e| format!(" {e}")).unwrap_or_default()),
None, None,
); );
} else { } else {
self.chat_widget self.chat_widget.add_info_message(
.add_info_message(format!("Model changed to {model}"), None); format!(
"Model changed to {model}{reasoning_effort}",
reasoning_effort =
effort.map(|e| format!(" {e}")).unwrap_or_default()
),
None,
);
} }
} }
Err(err) => { Err(err) => {
@@ -363,6 +373,25 @@ impl App {
AppEvent::OpenReviewCustomPrompt => { AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt(); self.chat_widget.show_review_custom_prompt();
} }
AppEvent::FullScreenApprovalRequest(request) => match request {
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
let _ = tui.enter_alt_screen();
let diff_summary = DiffSummary::new(changes, cwd);
self.overlay = Some(Overlay::new_static_with_renderables(
vec![diff_summary.into()],
"P A T C H".to_string(),
));
}
ApprovalRequest::Exec { command, .. } => {
let _ = tui.enter_alt_screen();
let full_cmd = strip_bash_lc_and_escape(&command);
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
self.overlay = Some(Overlay::new_static_with_lines(
full_cmd_lines,
"E X E C".to_string(),
));
}
},
} }
Ok(true) Ok(true)
} }

View File

@@ -4,6 +4,7 @@ use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event; use codex_core::protocol::Event;
use codex_file_search::FileMatch; use codex_file_search::FileMatch;
use crate::bottom_pane::ApprovalRequest;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use codex_core::protocol::AskForApproval; use codex_core::protocol::AskForApproval;
@@ -76,4 +77,7 @@ pub(crate) enum AppEvent {
/// Open the custom prompt option from the review popup. /// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt, OpenReviewCustomPrompt,
/// Open the approval popup.
FullScreenApprovalRequest(ApprovalRequest),
} }

View File

@@ -1,16 +1,21 @@
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView; use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::list_selection_view::HeaderLine;
use crate::bottom_pane::list_selection_view::ListSelectionView; use crate::bottom_pane::list_selection_view::ListSelectionView;
use crate::bottom_pane::list_selection_view::SelectionItem; use crate::bottom_pane::list_selection_view::SelectionItem;
use crate::bottom_pane::list_selection_view::SelectionViewParams; use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape; use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell; use crate::history_cell;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::text_formatting::truncate_text; use crate::text_formatting::truncate_text;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op; use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision; use codex_core::protocol::ReviewDecision;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
@@ -22,8 +27,11 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
/// Request coming from the agent that needs user approval. /// Request coming from the agent that needs user approval.
#[derive(Clone, Debug)]
pub(crate) enum ApprovalRequest { pub(crate) enum ApprovalRequest {
Exec { Exec {
id: String, id: String,
@@ -33,13 +41,15 @@ pub(crate) enum ApprovalRequest {
ApplyPatch { ApplyPatch {
id: String, id: String,
reason: Option<String>, reason: Option<String>,
grant_root: Option<PathBuf>, cwd: PathBuf,
changes: HashMap<PathBuf, FileChange>,
}, },
} }
/// Modal overlay asking the user to approve or deny one or more requests. /// Modal overlay asking the user to approve or deny one or more requests.
pub(crate) struct ApprovalOverlay { pub(crate) struct ApprovalOverlay {
current: Option<ApprovalRequestState>, current_request: Option<ApprovalRequest>,
current_variant: Option<ApprovalVariant>,
queue: Vec<ApprovalRequest>, queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
list: ListSelectionView, list: ListSelectionView,
@@ -51,23 +61,16 @@ pub(crate) struct ApprovalOverlay {
impl ApprovalOverlay { impl ApprovalOverlay {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let mut view = Self { let mut view = Self {
current: Some(ApprovalRequestState::from(request)), current_request: None,
current_variant: None,
queue: Vec::new(), queue: Vec::new(),
app_event_tx: app_event_tx.clone(), app_event_tx: app_event_tx.clone(),
list: ListSelectionView::new( list: ListSelectionView::new(Default::default(), app_event_tx),
SelectionViewParams {
title: String::new(),
..Default::default()
},
app_event_tx,
),
options: Vec::new(), options: Vec::new(),
current_complete: false, current_complete: false,
done: false, done: false,
}; };
let (options, params) = view.build_options(); view.set_current(request);
view.options = options;
view.list = ListSelectionView::new(params, view.app_event_tx.clone());
view view
} }
@@ -76,28 +79,30 @@ impl ApprovalOverlay {
} }
fn set_current(&mut self, request: ApprovalRequest) { fn set_current(&mut self, request: ApprovalRequest) {
self.current = Some(ApprovalRequestState::from(request)); self.current_request = Some(request.clone());
let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request);
self.current_variant = Some(variant.clone());
self.current_complete = false; self.current_complete = false;
let (options, params) = self.build_options(); let (options, params) = Self::build_options(variant, header);
self.options = options; self.options = options;
self.list = ListSelectionView::new(params, self.app_event_tx.clone()); self.list = ListSelectionView::new(params, self.app_event_tx.clone());
} }
fn build_options(&self) -> (Vec<ApprovalOption>, SelectionViewParams) { fn build_options(
let Some(state) = self.current.as_ref() else { variant: ApprovalVariant,
return ( header: Box<dyn Renderable>,
Vec::new(), ) -> (Vec<ApprovalOption>, SelectionViewParams) {
SelectionViewParams { let (options, title) = match &variant {
title: String::new(),
..Default::default()
},
);
};
let (options, title) = match &state.variant {
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()), ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()), ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
}; };
let header = Box::new(ColumnRenderable::new([
Box::new(Line::from(title.bold())),
Box::new(Line::from("")),
header,
]));
let items = options let items = options
.iter() .iter()
.map(|opt| SelectionItem { .map(|opt| SelectionItem {
@@ -111,10 +116,9 @@ impl ApprovalOverlay {
.collect(); .collect();
let params = SelectionViewParams { let params = SelectionViewParams {
title,
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()), footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
items, items,
header: state.header.clone(), header,
..Default::default() ..Default::default()
}; };
@@ -128,8 +132,8 @@ impl ApprovalOverlay {
let Some(option) = self.options.get(actual_idx) else { let Some(option) = self.options.get(actual_idx) else {
return; return;
}; };
if let Some(state) = self.current.as_ref() { if let Some(variant) = self.current_variant.as_ref() {
match (&state.variant, option.decision) { match (&variant, option.decision) {
(ApprovalVariant::Exec { id, command }, decision) => { (ApprovalVariant::Exec { id, command }, decision) => {
self.handle_exec_decision(id, command, decision); self.handle_exec_decision(id, command, decision);
} }
@@ -171,30 +175,43 @@ impl ApprovalOverlay {
} }
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool { fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press { match key_event {
return false; KeyEvent {
} kind: KeyEventKind::Press,
let KeyEvent { code: KeyCode::Char('a'),
code: KeyCode::Char(c), modifiers,
modifiers, ..
.. } if modifiers.contains(KeyModifiers::CONTROL) => {
} = key_event if let Some(request) = self.current_request.as_ref() {
else { self.app_event_tx
return false; .send(AppEvent::FullScreenApprovalRequest(request.clone()));
}; true
if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) { } else {
return false; false
} }
let lower = c.to_ascii_lowercase(); }
if let Some(idx) = self KeyEvent {
.options kind: KeyEventKind::Press,
.iter() code: KeyCode::Char(c),
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false)) modifiers,
{ ..
self.apply_selection(idx); } if !modifiers.contains(KeyModifiers::CONTROL)
true && !modifiers.contains(KeyModifiers::ALT) =>
} else { {
false let lower = c.to_ascii_lowercase();
match self
.options
.iter()
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
{
Some(idx) => {
self.apply_selection(idx);
true
}
None => false,
}
}
_ => false,
} }
} }
} }
@@ -215,9 +232,9 @@ impl BottomPaneView for ApprovalOverlay {
return CancellationEvent::Handled; return CancellationEvent::Handled;
} }
if !self.current_complete if !self.current_complete
&& let Some(state) = self.current.as_ref() && let Some(variant) = self.current_variant.as_ref()
{ {
match &state.variant { match &variant {
ApprovalVariant::Exec { id, command } => { ApprovalVariant::Exec { id, command } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort); self.handle_exec_decision(id, command, ReviewDecision::Abort);
} }
@@ -235,14 +252,6 @@ impl BottomPaneView for ApprovalOverlay {
self.done self.done
} }
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
fn try_consume_approval_request( fn try_consume_approval_request(
&mut self, &mut self,
request: ApprovalRequest, request: ApprovalRequest,
@@ -256,9 +265,19 @@ impl BottomPaneView for ApprovalOverlay {
} }
} }
impl Renderable for ApprovalOverlay {
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
}
struct ApprovalRequestState { struct ApprovalRequestState {
variant: ApprovalVariant, variant: ApprovalVariant,
header: Vec<HeaderLine>, header: Box<dyn Renderable>,
} }
impl From<ApprovalRequest> for ApprovalRequestState { impl From<ApprovalRequest> for ApprovalRequestState {
@@ -269,63 +288,50 @@ impl From<ApprovalRequest> for ApprovalRequestState {
command, command,
reason, reason,
} => { } => {
let mut header = Vec::new(); let mut header: Vec<Line<'static>> = Vec::new();
if let Some(reason) = reason if let Some(reason) = reason
&& !reason.is_empty() && !reason.is_empty()
{ {
header.push(HeaderLine::Text { header.push(reason.italic().into());
text: reason, header.push(Line::from(""));
italic: true,
});
header.push(HeaderLine::Spacer);
} }
let command_snippet = exec_snippet(&command); let full_cmd = strip_bash_lc_and_escape(&command);
if !command_snippet.is_empty() { let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
header.push(HeaderLine::Text { if let Some(first) = full_cmd_lines.first_mut() {
text: format!("Command: {command_snippet}"), first.spans.insert(0, Span::from("$ "));
italic: false,
});
header.push(HeaderLine::Spacer);
} }
header.extend(full_cmd_lines);
Self { Self {
variant: ApprovalVariant::Exec { id, command }, variant: ApprovalVariant::Exec { id, command },
header, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
} }
} }
ApprovalRequest::ApplyPatch { ApprovalRequest::ApplyPatch {
id, id,
reason, reason,
grant_root, cwd,
changes,
} => { } => {
let mut header = Vec::new(); let mut header: Vec<Box<dyn Renderable>> = Vec::new();
header.push(DiffSummary::new(changes, cwd).into());
if let Some(reason) = reason if let Some(reason) = reason
&& !reason.is_empty() && !reason.is_empty()
{ {
header.push(HeaderLine::Text { header.push(Box::new(Line::from("")));
text: reason, header.push(Box::new(
italic: true, Paragraph::new(reason.italic()).wrap(Wrap { trim: false }),
}); ));
header.push(HeaderLine::Spacer);
}
if let Some(root) = grant_root {
header.push(HeaderLine::Text {
text: format!(
"Grant write access to {} for the remainder of this session.",
root.display()
),
italic: false,
});
header.push(HeaderLine::Spacer);
} }
Self { Self {
variant: ApprovalVariant::ApplyPatch { id }, variant: ApprovalVariant::ApplyPatch { id },
header, header: Box::new(ColumnRenderable::new(header)),
} }
} }
} }
} }
} }
#[derive(Clone)]
enum ApprovalVariant { enum ApprovalVariant {
Exec { id: String, command: Vec<String> }, Exec { id: String, command: Vec<String> },
ApplyPatch { id: String }, ApplyPatch { id: String },
@@ -343,20 +349,20 @@ fn exec_options() -> Vec<ApprovalOption> {
vec![ vec![
ApprovalOption { ApprovalOption {
label: "Approve and run now".to_string(), label: "Approve and run now".to_string(),
description: "(Y) Run this command one time".to_string(), description: "Run this command one time".to_string(),
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
shortcut: Some('y'), shortcut: Some('y'),
}, },
ApprovalOption { ApprovalOption {
label: "Always approve this session".to_string(), label: "Always approve this session".to_string(),
description: "(A) Automatically approve this command for the rest of the session" description: "Automatically approve this command for the rest of the session"
.to_string(), .to_string(),
decision: ReviewDecision::ApprovedForSession, decision: ReviewDecision::ApprovedForSession,
shortcut: Some('a'), shortcut: Some('a'),
}, },
ApprovalOption { ApprovalOption {
label: "Cancel".to_string(), label: "Cancel".to_string(),
description: "(N) Do not run the command".to_string(), description: "Do not run the command".to_string(),
decision: ReviewDecision::Abort, decision: ReviewDecision::Abort,
shortcut: Some('n'), shortcut: Some('n'),
}, },
@@ -367,13 +373,13 @@ fn patch_options() -> Vec<ApprovalOption> {
vec![ vec![
ApprovalOption { ApprovalOption {
label: "Approve".to_string(), label: "Approve".to_string(),
description: "(Y) Apply the proposed changes".to_string(), description: "Apply the proposed changes".to_string(),
decision: ReviewDecision::Approved, decision: ReviewDecision::Approved,
shortcut: Some('y'), shortcut: Some('y'),
}, },
ApprovalOption { ApprovalOption {
label: "Cancel".to_string(), label: "Cancel".to_string(),
description: "(N) Do not apply the changes".to_string(), description: "Do not apply the changes".to_string(),
decision: ReviewDecision::Abort, decision: ReviewDecision::Abort,
shortcut: Some('n'), shortcut: Some('n'),
}, },
@@ -516,8 +522,8 @@ mod tests {
}; };
let view = ApprovalOverlay::new(exec_request, tx); let view = ApprovalOverlay::new(exec_request, tx);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6)); let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
view.render(Rect::new(0, 0, 80, 6), &mut buf); view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
let rendered: Vec<String> = (0..buf.area.height) let rendered: Vec<String> = (0..buf.area.height)
.map(|row| { .map(|row| {
@@ -529,7 +535,7 @@ mod tests {
assert!( assert!(
rendered rendered
.iter() .iter()
.any(|line| line.contains("Command: echo hello world")), .any(|line| line.contains("echo hello world")),
"expected header to include command snippet, got {rendered:?}" "expected header to include command snippet, got {rendered:?}"
); );
} }

View File

@@ -1,12 +1,12 @@
use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::ApprovalRequest;
use crate::render::renderable::Renderable;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use super::CancellationEvent; use super::CancellationEvent;
/// Trait implemented by every view that can be shown in the bottom pane. /// Trait implemented by every view that can be shown in the bottom pane.
pub(crate) trait BottomPaneView { pub(crate) trait BottomPaneView: Renderable {
/// Handle a key event while the view is active. A redraw is always /// Handle a key event while the view is active. A redraw is always
/// scheduled after this call. /// scheduled after this call.
fn handle_key_event(&mut self, _key_event: KeyEvent) {} fn handle_key_event(&mut self, _key_event: KeyEvent) {}
@@ -21,12 +21,6 @@ pub(crate) trait BottomPaneView {
CancellationEvent::NotHandled CancellationEvent::NotHandled
} }
/// Return the desired height of the view.
fn desired_height(&self, width: u16) -> u16;
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Optional paste handler. Return true if the view modified its state and /// Optional paste handler. Return true if the view modified its state and
/// needs a redraw. /// needs a redraw.
fn handle_paste(&mut self, _pasted: String) -> bool { fn handle_paste(&mut self, _pasted: String) -> bool {

View File

@@ -6,6 +6,8 @@ use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState; use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows; use super::selection_popup_common::render_rows;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand; use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands; use crate::slash_command::built_in_slash_commands;
use codex_common::fuzzy_match::fuzzy_match; use codex_common::fuzzy_match::fuzzy_match;
@@ -205,13 +207,12 @@ impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) { fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows = self.rows_from_matches(self.filtered()); let rows = self.rows_from_matches(self.filtered());
render_rows( render_rows(
area, area.inset(Insets::tlbr(0, 2, 0, 0)),
buf, buf,
&rows, &rows,
&self.state, &self.state,
MAX_POPUP_ROWS, MAX_POPUP_ROWS,
"no matches", "no matches",
false,
); );
} }
} }

View File

@@ -12,6 +12,8 @@ use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use std::cell::RefCell; use std::cell::RefCell;
use crate::render::renderable::Renderable;
use super::popup_consts::STANDARD_POPUP_HINT_LINE; use super::popup_consts::STANDARD_POPUP_HINT_LINE;
use super::CancellationEvent; use super::CancellationEvent;
@@ -94,6 +96,36 @@ impl BottomPaneView for CustomPromptView {
self.complete self.complete
} }
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl Renderable for CustomPromptView {
fn desired_height(&self, width: u16) -> u16 { fn desired_height(&self, width: u16) -> u16 {
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 }; let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
1u16 + extra_top + self.input_height(width) + 3u16 1u16 + extra_top + self.input_height(width) + 3u16
@@ -200,34 +232,6 @@ impl BottomPaneView for CustomPromptView {
); );
} }
} }
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
} }
impl CustomPromptView { impl CustomPromptView {

View File

@@ -3,6 +3,9 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use crate::render::Insets;
use crate::render::RectExt;
use super::popup_consts::MAX_POPUP_ROWS; use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState; use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow; use super::selection_popup_common::GenericDisplayRow;
@@ -139,13 +142,12 @@ impl WidgetRef for &FileSearchPopup {
}; };
render_rows( render_rows(
area, area.inset(Insets::tlbr(0, 2, 0, 0)),
buf, buf,
&rows_all, &rows_all,
&self.state, &self.state,
MAX_POPUP_ROWS, MAX_POPUP_ROWS,
empty_message, empty_message,
false,
); );
} }
} }

View File

@@ -2,15 +2,23 @@ use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers; use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use textwrap::wrap;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::terminal_palette;
use super::CancellationEvent; use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView; use super::bottom_pane_view::BottomPaneView;
@@ -23,12 +31,6 @@ use super::selection_popup_common::render_rows;
/// One selectable item in the generic selection list. /// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>; pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HeaderLine {
Text { text: String, italic: bool },
Spacer,
}
pub(crate) struct SelectionItem { pub(crate) struct SelectionItem {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
@@ -38,20 +40,31 @@ pub(crate) struct SelectionItem {
pub search_value: Option<String>, pub search_value: Option<String>,
} }
#[derive(Default)]
pub(crate) struct SelectionViewParams { pub(crate) struct SelectionViewParams {
pub title: String, pub title: Option<String>,
pub subtitle: Option<String>, pub subtitle: Option<String>,
pub footer_hint: Option<String>, pub footer_hint: Option<String>,
pub items: Vec<SelectionItem>, pub items: Vec<SelectionItem>,
pub is_searchable: bool, pub is_searchable: bool,
pub search_placeholder: Option<String>, pub search_placeholder: Option<String>,
pub header: Vec<HeaderLine>, pub header: Box<dyn Renderable>,
}
impl Default for SelectionViewParams {
fn default() -> Self {
Self {
title: None,
subtitle: None,
footer_hint: None,
items: Vec::new(),
is_searchable: false,
search_placeholder: None,
header: Box::new(()),
}
}
} }
pub(crate) struct ListSelectionView { pub(crate) struct ListSelectionView {
title: String,
subtitle: Option<String>,
footer_hint: Option<String>, footer_hint: Option<String>,
items: Vec<SelectionItem>, items: Vec<SelectionItem>,
state: ScrollState, state: ScrollState,
@@ -62,23 +75,22 @@ pub(crate) struct ListSelectionView {
search_placeholder: Option<String>, search_placeholder: Option<String>,
filtered_indices: Vec<usize>, filtered_indices: Vec<usize>,
last_selected_actual_idx: Option<usize>, last_selected_actual_idx: Option<usize>,
header: Vec<HeaderLine>, header: Box<dyn Renderable>,
} }
impl ListSelectionView { impl ListSelectionView {
fn dim_prefix_span() -> Span<'static> {
"".dim()
}
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
para.render(area, buf);
}
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self { pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
let mut header = params.header;
if params.title.is_some() || params.subtitle.is_some() {
let title = params.title.map(|title| Line::from(title.bold()));
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
header = Box::new(ColumnRenderable::new([
header,
Box::new(title),
Box::new(subtitle),
]));
}
let mut s = Self { let mut s = Self {
title: params.title,
subtitle: params.subtitle,
footer_hint: params.footer_hint, footer_hint: params.footer_hint,
items: params.items, items: params.items,
state: ScrollState::new(), state: ScrollState::new(),
@@ -93,7 +105,7 @@ impl ListSelectionView {
}, },
filtered_indices: Vec::new(), filtered_indices: Vec::new(),
last_selected_actual_idx: None, last_selected_actual_idx: None,
header: params.header, header,
}; };
s.apply_filter(); s.apply_filter();
s s
@@ -171,7 +183,7 @@ impl ListSelectionView {
.filter_map(|(visible_idx, actual_idx)| { .filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| { self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx); let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '>' } else { ' ' }; let prefix = if is_selected { '' } else { ' ' };
let name = item.name.as_str(); let name = item.name.as_str();
let name_with_marker = if item.is_current { let name_with_marker = if item.is_current {
format!("{name} (current)") format!("{name} (current)")
@@ -179,7 +191,13 @@ impl ListSelectionView {
item.name.clone() item.name.clone()
}; };
let n = visible_idx + 1; let n = visible_idx + 1;
let display_name = format!("{prefix} {n}. {name_with_marker}"); let display_name = if self.is_searchable {
// The number keys don't work when search is enabled (since we let the
// numbers be used for the search query).
format!("{prefix} {name_with_marker}")
} else {
format!("{prefix} {n}. {name_with_marker}")
};
GenericDisplayRow { GenericDisplayRow {
name: display_name, name: display_name,
match_indices: None, match_indices: None,
@@ -231,39 +249,6 @@ impl ListSelectionView {
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> { pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
self.last_selected_actual_idx.take() self.last_selected_actual_idx.take()
} }
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
if self.header.is_empty() || width == 0 {
return Vec::new();
}
let prefix_width = Self::dim_prefix_span().width() as u16;
let available = width.saturating_sub(prefix_width).max(1) as usize;
let mut lines = Vec::new();
for entry in &self.header {
match entry {
HeaderLine::Spacer => lines.push(Vec::new()),
HeaderLine::Text { text, italic } => {
if text.is_empty() {
lines.push(Vec::new());
continue;
}
for part in wrap(text, available) {
let span = if *italic {
Span::from(part.into_owned()).italic()
} else {
Span::from(part.into_owned())
};
lines.push(vec![span]);
}
}
}
}
lines
}
fn header_height(&self, width: u16) -> u16 {
self.header_spans_for_width(width).len() as u16
}
} }
impl BottomPaneView for ListSelectionView { impl BottomPaneView for ListSelectionView {
@@ -299,6 +284,24 @@ impl BottomPaneView for ListSelectionView {
self.search_query.push(c); self.search_query.push(c);
self.apply_filter(); self.apply_filter();
} }
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !self.is_searchable
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
if let Some(idx) = c
.to_digit(10)
.map(|d| d as usize)
.and_then(|d| d.checked_sub(1))
&& idx < self.items.len()
{
self.state.selected_idx = Some(idx);
self.accept();
}
}
KeyEvent { KeyEvent {
code: KeyCode::Enter, code: KeyCode::Enter,
modifiers: KeyModifiers::NONE, modifiers: KeyModifiers::NONE,
@@ -316,7 +319,9 @@ impl BottomPaneView for ListSelectionView {
self.complete = true; self.complete = true;
CancellationEvent::Handled CancellationEvent::Handled
} }
}
impl Renderable for ListSelectionView {
fn desired_height(&self, width: u16) -> u16 { fn desired_height(&self, width: u16) -> u16 {
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width. // Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
// Build the same display rows used by the renderer so wrapping math matches. // Build the same display rows used by the renderer so wrapping math matches.
@@ -324,19 +329,13 @@ impl BottomPaneView for ListSelectionView {
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width); let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
// +1 for the title row, +1 for a spacer line beneath the header, let mut height = self.header.desired_height(width);
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing) height = height.saturating_add(rows_height + 3);
let mut height = self.header_height(width);
height = height.saturating_add(rows_height + 2);
if self.is_searchable { if self.is_searchable {
height = height.saturating_add(1); height = height.saturating_add(1);
} }
if self.subtitle.is_some() {
// +1 for subtitle (the spacer is accounted for above)
height = height.saturating_add(1);
}
if self.footer_hint.is_some() { if self.footer_hint.is_some() {
height = height.saturating_add(2); height = height.saturating_add(1);
} }
height height
} }
@@ -346,52 +345,42 @@ impl BottomPaneView for ListSelectionView {
return; return;
} }
let mut next_y = area.y; let [content_area, footer_area] = Layout::vertical([
let header_spans = self.header_spans_for_width(area.width); Constraint::Fill(1),
for spans in header_spans.into_iter() { Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }),
if next_y >= area.y + area.height { ])
return; .areas(area);
}
let row = Rect { Block::default()
x: area.x, .style(user_message_style(terminal_palette::default_bg()))
y: next_y, .render(content_area, buf);
width: area.width,
height: 1, let header_height = self.header.desired_height(content_area.width);
}; let rows = self.build_rows();
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()]; let rows_height =
if spans.is_empty() { measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, content_area.width);
prefixed.push(String::new().into()); let [header_area, _, search_area, list_area] = Layout::vertical([
} else { Constraint::Max(header_height),
prefixed.extend(spans); Constraint::Max(1),
} Constraint::Length(if self.is_searchable { 1 } else { 0 }),
Paragraph::new(Line::from(prefixed)).render(row, buf); Constraint::Length(rows_height),
next_y = next_y.saturating_add(1); ])
.areas(content_area.inset(Insets::vh(1, 2)));
if header_area.height < header_height {
let [header_area, elision_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
self.header.render(header_area, buf);
Paragraph::new(vec![
Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(),
])
.render(elision_area, buf);
} else {
self.header.render(header_area, buf);
} }
if next_y >= area.y + area.height { if self.is_searchable {
return; Line::from(self.search_query.clone()).render(search_area, buf);
}
let title_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![
Self::dim_prefix_span(),
self.title.clone().bold(),
]))
.render(title_area, buf);
next_y = next_y.saturating_add(1);
if self.is_searchable && next_y < area.y + area.height {
let search_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let query_span: Span<'static> = if self.search_query.is_empty() { let query_span: Span<'static> = if self.search_query.is_empty() {
self.search_placeholder self.search_placeholder
.as_ref() .as_ref()
@@ -400,80 +389,40 @@ impl BottomPaneView for ListSelectionView {
} else { } else {
self.search_query.clone().into() self.search_query.clone().into()
}; };
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span])) Line::from(query_span).render(search_area, buf);
.render(search_area, buf);
next_y = next_y.saturating_add(1);
} }
if let Some(sub) = &self.subtitle { if list_area.height > 0 {
if next_y >= area.y + area.height { let list_area = Rect {
return; x: list_area.x - 2,
} y: list_area.y,
let subtitle_area = Rect { width: list_area.width + 2,
x: area.x, height: list_area.height,
y: next_y,
width: area.width,
height: 1,
}; };
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
.render(subtitle_area, buf);
next_y = next_y.saturating_add(1);
}
if next_y >= area.y + area.height {
return;
}
let spacer_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Self::render_dim_prefix_line(spacer_area, buf);
next_y = next_y.saturating_add(1);
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
if next_y >= area.y + area.height {
return;
}
let rows_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: area
.height
.saturating_sub(next_y.saturating_sub(area.y))
.saturating_sub(footer_reserved),
};
let rows = self.build_rows();
if rows_area.height > 0 {
render_rows( render_rows(
rows_area, list_area,
buf, buf,
&rows, &rows,
&self.state, &self.state,
MAX_POPUP_ROWS, list_area.height as usize,
"no matches", "no matches",
true,
); );
} }
if let Some(hint) = &self.footer_hint { if let Some(hint) = &self.footer_hint {
let footer_area = Rect { let hint_area = Rect {
x: area.x, x: footer_area.x + 2,
y: area.y + area.height - 1, y: footer_area.y,
width: area.width, width: footer_area.width.saturating_sub(2),
height: 1, height: footer_area.height,
}; };
Paragraph::new(hint.clone().dim()).render(footer_area, buf); Line::from(hint.clone().dim()).render(hint_area, buf);
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::BottomPaneView;
use super::*; use super::*;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE; use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
@@ -504,7 +453,7 @@ mod tests {
]; ];
ListSelectionView::new( ListSelectionView::new(
SelectionViewParams { SelectionViewParams {
title: "Select Approval Mode".to_string(), title: Some("Select Approval Mode".to_string()),
subtitle: subtitle.map(str::to_string), subtitle: subtitle.map(str::to_string),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items, items,
@@ -516,7 +465,7 @@ mod tests {
fn render_lines(view: &ListSelectionView) -> String { fn render_lines(view: &ListSelectionView) -> String {
let width = 48; let width = 48;
let height = BottomPaneView::desired_height(view, width); let height = view.desired_height(width);
let area = Rect::new(0, 0, width, height); let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
view.render(area, &mut buf); view.render(area, &mut buf);
@@ -567,7 +516,7 @@ mod tests {
}]; }];
let mut view = ListSelectionView::new( let mut view = ListSelectionView::new(
SelectionViewParams { SelectionViewParams {
title: "Select Approval Mode".to_string(), title: Some("Select Approval Mode".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items, items,
is_searchable: true, is_searchable: true,
@@ -579,6 +528,9 @@ mod tests {
view.set_search_query("filters".to_string()); view.set_search_query("filters".to_string());
let lines = render_lines(&view); let lines = render_lines(&view);
assert!(lines.contains("▌ filters")); assert!(
lines.contains("filters"),
"expected search query line to include rendered query, got {lines:?}"
);
} }
} }

View File

@@ -3,17 +3,13 @@ use ratatui::layout::Rect;
// Note: Table-based layout previously used Constraint; the manual renderer // Note: Table-based layout previously used Constraint; the manual renderer
// below no longer requires it. // below no longer requires it.
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::text::Span; use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
use super::scroll_state::ScrollState; use super::scroll_state::ScrollState;
use crate::ui_consts::LIVE_PREFIX_COLS;
/// A generic representation of a display row for selection popups. /// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow { pub(crate) struct GenericDisplayRow {
@@ -23,8 +19,6 @@ pub(crate) struct GenericDisplayRow {
pub description: Option<String>, // optional grey text after the name pub description: Option<String>, // optional grey text after the name
} }
impl GenericDisplayRow {}
/// Compute a shared description-column start based on the widest visible name /// Compute a shared description-column start based on the widest visible name
/// plus two spaces of padding. Ensures at least one column is left for the /// plus two spaces of padding. Ensures at least one column is left for the
/// description. /// description.
@@ -117,71 +111,19 @@ pub(crate) fn render_rows(
state: &ScrollState, state: &ScrollState,
max_results: usize, max_results: usize,
empty_message: &str, empty_message: &str,
include_border: bool,
) { ) {
if include_border {
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
// Always draw a dim left border to match other popups.
let block = Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().add_modifier(Modifier::DIM));
block.render(area, buf);
}
// Content renders to the right of the border with the same live prefix
// padding used by the composer so the popup aligns with the input text.
let prefix_cols = LIVE_PREFIX_COLS;
let content_area = Rect {
x: area.x.saturating_add(prefix_cols),
y: area.y,
width: area.width.saturating_sub(prefix_cols),
height: area.height,
};
// Clear the padding column(s) so stale characters never peek between the
// border and the popup contents.
let padding_cols = prefix_cols.saturating_sub(1);
if padding_cols > 0 {
let pad_start = area.x.saturating_add(1);
let pad_end = pad_start
.saturating_add(padding_cols)
.min(area.x.saturating_add(area.width));
let pad_bottom = area.y.saturating_add(area.height);
for x in pad_start..pad_end {
for y in area.y..pad_bottom {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(" ");
}
}
}
}
if rows_all.is_empty() { if rows_all.is_empty() {
if content_area.height > 0 { if area.height > 0 {
let para = Paragraph::new(Line::from(empty_message.dim().italic())); Line::from(empty_message.dim().italic()).render(area, buf);
para.render(
Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: 1,
},
buf,
);
} }
return; return;
} }
// Determine which logical rows (items) are visible given the selection and // Determine which logical rows (items) are visible given the selection and
// the max_results clamp. Scrolling is still item-based for simplicity. // the max_results clamp. Scrolling is still item-based for simplicity.
let max_rows_from_area = content_area.height as usize;
let visible_items = max_results let visible_items = max_results
.min(rows_all.len()) .min(rows_all.len())
.min(max_rows_from_area.max(1)); .min(area.height.max(1) as usize);
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1)); let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx { if let Some(sel) = state.selected_idx {
@@ -195,18 +137,18 @@ pub(crate) fn render_rows(
} }
} }
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width); let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width);
// Render items, wrapping descriptions and aligning wrapped lines under the // Render items, wrapping descriptions and aligning wrapped lines under the
// shared description column. Stop when we run out of vertical space. // shared description column. Stop when we run out of vertical space.
let mut cur_y = content_area.y; let mut cur_y = area.y;
for (i, row) in rows_all for (i, row) in rows_all
.iter() .iter()
.enumerate() .enumerate()
.skip(start_idx) .skip(start_idx)
.take(visible_items) .take(visible_items)
{ {
if cur_y >= content_area.y + content_area.height { if cur_y >= area.y + area.height {
break; break;
} }
@@ -217,7 +159,7 @@ pub(crate) fn render_rows(
description, description,
} = row; } = row;
let full_line = build_full_line( let mut full_line = build_full_line(
&GenericDisplayRow { &GenericDisplayRow {
name: name.clone(), name: name.clone(),
match_indices: match_indices.clone(), match_indices: match_indices.clone(),
@@ -226,32 +168,31 @@ pub(crate) fn render_rows(
}, },
desc_col, desc_col,
); );
if Some(i) == state.selected_idx {
// Match previous behavior: cyan + bold for the selected row.
full_line.spans.iter_mut().for_each(|span| {
span.style = span.style.fg(Color::Cyan).bold();
});
}
// Wrap with subsequent indent aligned to the description column. // Wrap with subsequent indent aligned to the description column.
use crate::wrapping::RtOptions; use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_line;
let options = RtOptions::new(content_area.width as usize) let options = RtOptions::new(area.width as usize)
.initial_indent(Line::from("")) .initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(desc_col))); .subsequent_indent(Line::from(" ".repeat(desc_col)));
let wrapped = word_wrap_line(&full_line, options); let wrapped = word_wrap_line(&full_line, options);
// Render the wrapped lines. // Render the wrapped lines.
for mut line in wrapped { for line in wrapped {
if cur_y >= content_area.y + content_area.height { if cur_y >= area.y + area.height {
break; break;
} }
if Some(i) == state.selected_idx { line.render(
// Match previous behavior: cyan + bold for the selected row.
line.style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
}
let para = Paragraph::new(line);
para.render(
Rect { Rect {
x: content_area.x, x: area.x,
y: cur_y, y: cur_y,
width: content_area.width, width: area.width,
height: 1, height: 1,
}, },
buf, buf,

View File

@@ -2,10 +2,11 @@
source: tui/src/bottom_pane/list_selection_view.rs source: tui/src/bottom_pane/list_selection_view.rs
expression: render_lines(&view) expression: render_lines(&view)
--- ---
▌ Select Approval Mode
▌ Switch between Codex approval presets
▌ > 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back Select Approval Mode
Switch between Codex approval presets
1. Read Only (current) Codex can read files
2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back

View File

@@ -2,9 +2,10 @@
source: tui/src/bottom_pane/list_selection_view.rs source: tui/src/bottom_pane/list_selection_view.rs
expression: render_lines(&view) expression: render_lines(&view)
--- ---
▌ Select Approval Mode
▌ > 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back Select Approval Mode
1. Read Only (current) Codex can read files
2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back

View File

@@ -79,7 +79,6 @@ use crate::history_cell;
use crate::history_cell::AgentMessageCell; use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell; use crate::history_cell::McpToolCallCell;
use crate::history_cell::PatchEventType;
use crate::markdown::append_markdown; use crate::markdown::append_markdown;
use crate::slash_command::SlashCommand; use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay; use crate::status::RateLimitSnapshotDisplay;
@@ -534,9 +533,6 @@ impl ChatWidget {
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
self.add_to_history(history_cell::new_patch_event( self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApplyBegin {
auto_approved: event.auto_approved,
},
event.changes, event.changes,
&self.config.cwd, &self.config.cwd,
)); ));
@@ -736,8 +732,6 @@ impl ChatWidget {
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) { pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
self.flush_answer_stream_with_separator(); self.flush_answer_stream_with_separator();
// Emit the proposed command into history (like proposed patches)
self.add_to_history(history_cell::new_proposed_command(&ev.command));
let command = shlex::try_join(ev.command.iter().map(String::as_str)) let command = shlex::try_join(ev.command.iter().map(String::as_str))
.unwrap_or_else(|_| ev.command.join(" ")); .unwrap_or_else(|_| ev.command.join(" "));
self.notify(Notification::ExecApprovalRequested { command }); self.notify(Notification::ExecApprovalRequested { command });
@@ -757,16 +751,12 @@ impl ChatWidget {
ev: ApplyPatchApprovalRequestEvent, ev: ApplyPatchApprovalRequestEvent,
) { ) {
self.flush_answer_stream_with_separator(); self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApprovalRequest,
ev.changes.clone(),
&self.config.cwd,
));
let request = ApprovalRequest::ApplyPatch { let request = ApprovalRequest::ApplyPatch {
id, id,
reason: ev.reason, reason: ev.reason,
grant_root: ev.grant_root, changes: ev.changes.clone(),
cwd: self.config.cwd.clone(),
}; };
self.bottom_pane.push_approval_request(request); self.bottom_pane.push_approval_request(request);
self.request_redraw(); self.request_redraw();
@@ -1631,7 +1621,7 @@ impl ChatWidget {
} }
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select model and reasoning level".to_string(), title: Some("Select model and reasoning level".to_string()),
subtitle: Some( subtitle: Some(
"Switch between OpenAI models for this and future Codex CLI session".to_string(), "Switch between OpenAI models for this and future Codex CLI session".to_string(),
), ),
@@ -1677,7 +1667,7 @@ impl ChatWidget {
} }
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select Approval Mode".to_string(), title: Some("Select Approval Mode".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items, items,
..Default::default() ..Default::default()
@@ -1852,7 +1842,7 @@ impl ChatWidget {
}); });
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a review preset".into(), title: Some("Select a review preset".into()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items, items,
..Default::default() ..Default::default()
@@ -1888,7 +1878,7 @@ impl ChatWidget {
} }
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a base branch".to_string(), title: Some("Select a base branch".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items, items,
is_searchable: true, is_searchable: true,
@@ -1929,7 +1919,7 @@ impl ChatWidget {
} }
self.bottom_pane.show_selection_view(SelectionViewParams { self.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a commit to review".to_string(), title: Some("Select a commit to review".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items, items,
is_searchable: true, is_searchable: true,
@@ -2154,7 +2144,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
} }
chat.bottom_pane.show_selection_view(SelectionViewParams { chat.bottom_pane.show_selection_view(SelectionViewParams {
title: "Select a commit to review".to_string(), title: Some("Select a commit to review".to_string()),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()), footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items, items,
is_searchable: true, is_searchable: true,

View File

@@ -2,4 +2,5 @@
source: tui/src/chatwidget/tests.rs source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&approved_lines) expression: lines_to_single_string(&approved_lines)
--- ---
Change Approved foo.txt (+1 -0) Added foo.txt (+1 -0)
1 +hello

View File

@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_lines)
---
• Proposed Change foo.txt (+1 -0)
1 +hello

View File

@@ -1,18 +1,16 @@
--- ---
source: tui/src/chatwidget/tests.rs source: tui/src/chatwidget/tests.rs
expression: terminal.backend() expression: terminal.backend().vt100().screen().contents()
--- ---
" " Allow command?
"▌ this is a test reason such as one that would be produced by the model "
"▌ " this is a test reason such as one that would be produced by the model
"▌ Command: echo hello world "
"▌ " $ echo hello world
"▌ Allow command? "
"▌ " 1. Approve and run now Run this command one time
"▌ > 1. Approve and run now (Y) Run this command one time " 2. Always approve this session Automatically approve this command for the
"▌ 2. Always approve this session (A) Automatically approve this command for " rest of the session
"▌ the rest of the session " 3. Cancel Do not run the command
"▌ 3. Cancel (N) Do not run the command "
" " Press Enter to confirm or Esc to cancel
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -3,14 +3,15 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" " " "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" " " "
"Press Enter to confirm or Esc to cancel " " Allow command? "
" "
" $ echo hello world "
" "
" 1. Approve and run now Run this command one time "
" 2. Always approve this session Automatically approve this command for the "
" rest of the session "
" 3. Cancel Do not run the command "
" "
" Press Enter to confirm or Esc to cancel "
" " " "

View File

@@ -3,14 +3,17 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" " " "
"▌ The model wants to apply changes "
"▌ "
"▌ Grant write access to /tmp for the remainder of this session. "
"▌ "
"▌ Apply changes? "
"▌ "
"▌ > 1. Approve (Y) Apply the proposed changes "
"▌ 2. Cancel (N) Do not apply the changes "
" " " "
"Press Enter to confirm or Esc to cancel " " Apply changes? "
" "
" README.md (+2 -0) "
" 1 +hello "
" 2 +world "
" "
" The model wants to apply changes "
" "
" 1. Approve Apply the proposed changes "
" 2. Cancel Do not apply the changes "
" "
" Press Enter to confirm or Esc to cancel "
" " " "

View File

@@ -1,7 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_multi)
---
• Proposed Command
└ echo line1
echo line2

View File

@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed)
---
• Proposed Command
└ echo hello world

View File

@@ -0,0 +1,42 @@
---
source: tui/src/chatwidget/tests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 15 },
content: [
" ",
" ",
" Allow command? ",
" ",
" this is a test reason such as one that would be produced by the model ",
" ",
" $ echo hello world ",
" ",
" 1. Approve and run now Run this command one time ",
" 2. Always approve this session Automatically approve this command for the ",
" rest of the session ",
" 3. Cancel Do not run the command ",
" ",
" Press Enter to confirm or Esc to cancel ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 71, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 8, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 34, y: 8, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD | DIM,
x: 59, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 76, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 53, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 56, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 41, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}

View File

@@ -3,16 +3,17 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
" " " "
"▌ this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo 'hello world' "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" " " "
"Press Enter to confirm or Esc to cancel " " Allow command? "
" "
" this is a test reason such as one that would be produced by the model "
" "
" $ echo 'hello world' "
" "
" 1. Approve and run now Run this command one time "
" 2. Always approve this session Automatically approve this command for the "
" rest of the session "
" 3. Cancel Do not run the command "
" "
" Press Enter to confirm or Esc to cancel "
" " " "

View File

@@ -83,66 +83,6 @@ fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json
payload payload
} }
/*
#[test]
fn final_answer_without_newline_is_flushed_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Set up a VT100 test terminal to capture ANSI visual output
let width: u16 = 80;
// Increased height to keep the initial banner/help lines in view even if
// the session renders an extra header line or minor layout changes occur.
let height: u16 = 2500;
let viewport = Rect::new(0, height - 1, width, 1);
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
terminal.set_viewport_area(viewport);
// Simulate a streaming answer without any newline characters.
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Hi! How can I help with codex-rs or anything else today?".into(),
}),
});
// Now simulate the final AgentMessage which should flush the pending line immediately.
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Hi! How can I help with codex-rs or anything else today?".into(),
}),
});
// Drain history insertions and verify the final line is present.
let cells = drain_insert_history(&mut rx);
assert!(
cells.iter().any(|lines| {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
s.contains("codex")
}),
"expected 'codex' header to be emitted",
);
let found_final = cells.iter().any(|lines| {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
s.contains("Hi! How can I help with codex-rs or anything else today?")
});
assert!(
found_final,
"expected final answer text to be flushed to history"
);
}
*/
#[test] #[test]
fn resumed_initial_messages_render_history() { fn resumed_initial_messages_render_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(); let (mut chat, mut rx, _ops) = make_chatwidget_manual();
@@ -452,15 +392,18 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
msg: EventMsg::ExecApprovalRequest(ev), msg: EventMsg::ExecApprovalRequest(ev),
}); });
// Snapshot the Proposed Command cell emitted into history let proposed_cells = drain_insert_history(&mut rx);
let proposed = drain_insert_history(&mut rx) assert!(
.pop() proposed_cells.is_empty(),
.expect("expected proposed command cell"); "expected approval request to render via modal without emitting history cells"
assert_snapshot!(
"exec_approval_history_proposed_short",
lines_to_single_string(&proposed)
); );
// The approval modal should display the command snippet for user confirmation.
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}"));
// Approve via keyboard and verify a concise decision history line is added // Approve via keyboard and verify a concise decision history line is added
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let decision = drain_insert_history(&mut rx) let decision = drain_insert_history(&mut rx)
@@ -476,7 +419,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
fn exec_approval_decision_truncates_multiline_and_long_commands() { fn exec_approval_decision_truncates_multiline_and_long_commands() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Multiline command: should render proposed command fully in history with prefixes // Multiline command: modal should show full command, history records decision only
let ev_multi = ExecApprovalRequestEvent { let ev_multi = ExecApprovalRequestEvent {
call_id: "call-multi".into(), call_id: "call-multi".into(),
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()], command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
@@ -489,12 +432,29 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
id: "sub-multi".into(), id: "sub-multi".into(),
msg: EventMsg::ExecApprovalRequest(ev_multi), msg: EventMsg::ExecApprovalRequest(ev_multi),
}); });
let proposed_multi = drain_insert_history(&mut rx) let proposed_multi = drain_insert_history(&mut rx);
.pop() assert!(
.expect("expected proposed multiline command cell"); proposed_multi.is_empty(),
assert_snapshot!( "expected multiline approval request to render via modal without emitting history cells"
"exec_approval_history_proposed_multiline", );
lines_to_single_string(&proposed_multi)
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_first_line = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("echo line1") {
saw_first_line = true;
break;
}
}
assert!(
saw_first_line,
"expected modal to show first line of multiline snippet"
); );
// Deny via keyboard; decision snippet should be single-line and elided with " ..." // Deny via keyboard; decision snippet should be single-line and elided with " ..."
@@ -519,7 +479,11 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
id: "sub-long".into(), id: "sub-long".into(),
msg: EventMsg::ExecApprovalRequest(ev_long), msg: EventMsg::ExecApprovalRequest(ev_long),
}); });
drain_insert_history(&mut rx); // proposed cell not needed for this assertion let proposed_long = drain_insert_history(&mut rx);
assert!(
proposed_long.is_empty(),
"expected long approval request to avoid emitting history cells before decision"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let aborted_long = drain_insert_history(&mut rx) let aborted_long = drain_insert_history(&mut rx)
.pop() .pop()
@@ -935,18 +899,21 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
let area = Rect::new(0, 0, width, height); let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
(chat).render_ref(area, &mut buf); (chat).render_ref(area, &mut buf);
let mut row = String::new(); for y in 0..area.height {
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line let mut row = String::new();
let y = 1u16.min(height.saturating_sub(1)); for x in 0..area.width {
for x in 0..area.width { let s = buf[(x, y)].symbol();
let s = buf[(x, y)].symbol(); if s.is_empty() {
if s.is_empty() { row.push(' ');
row.push(' '); } else {
} else { row.push_str(s);
row.push_str(s); }
}
if !row.trim().is_empty() {
return row;
} }
} }
row String::new()
} }
#[test] #[test]
@@ -1181,12 +1148,19 @@ fn approval_modal_exec_snapshot() {
// Render to a fixed-size test terminal and snapshot. // Render to a fixed-size test terminal and snapshot.
// Call desired_height first and use that exact height for rendering. // Call desired_height first and use that exact height for rendering.
let height = chat.desired_height(80); let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) let mut terminal =
.expect("create terminal"); crate::custom_terminal::Terminal::with_options(VT100Backend::new(80, height))
.expect("create terminal");
let viewport = Rect::new(0, 0, 80, height);
terminal.set_viewport_area(viewport);
terminal terminal
.draw(|f| f.render_widget_ref(&chat, f.area())) .draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw approval modal"); .expect("draw approval modal");
assert_snapshot!("approval_modal_exec", terminal.backend()); assert_snapshot!(
"approval_modal_exec",
terminal.backend().vt100().screen().contents()
);
} }
// Snapshot test: command approval modal without a reason // Snapshot test: command approval modal without a reason
@@ -1470,13 +1444,27 @@ fn apply_patch_events_emit_history_cells() {
msg: EventMsg::ApplyPatchApprovalRequest(ev), msg: EventMsg::ApplyPatchApprovalRequest(ev),
}); });
let cells = drain_insert_history(&mut rx); let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected pending patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!( assert!(
blob.contains("Proposed Change"), cells.is_empty(),
"missing proposed change header: {blob:?}" "expected approval request to surface via modal without emitting history cells"
); );
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_summary = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("foo.txt (+1 -0)") {
saw_summary = true;
break;
}
}
assert!(saw_summary, "expected approval modal to show diff summary");
// 2) Begin apply -> per-file apply block cell (no global header) // 2) Begin apply -> per-file apply block cell (no global header)
let mut changes2 = HashMap::new(); let mut changes2 = HashMap::new();
changes2.insert( changes2.insert(
@@ -1562,8 +1550,8 @@ fn apply_patch_manual_approval_adjusts_header() {
assert!(!cells.is_empty(), "expected apply block cell to be sent"); assert!(!cells.is_empty(), "expected apply block cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap()); let blob = lines_to_single_string(cells.last().unwrap());
assert!( assert!(
blob.contains("Change Approved foo.txt"), blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"),
"expected change approved summary: {blob:?}" "expected apply summary header for foo.txt: {blob:?}"
); );
} }
@@ -1587,9 +1575,11 @@ fn apply_patch_manual_flow_snapshot() {
grant_root: None, grant_root: None,
}), }),
}); });
let proposed_lines = drain_insert_history(&mut rx) let history_before_apply = drain_insert_history(&mut rx);
.pop() assert!(
.expect("proposed patch cell"); history_before_apply.is_empty(),
"expected approval modal to defer history emission"
);
let mut apply_changes = HashMap::new(); let mut apply_changes = HashMap::new();
apply_changes.insert( apply_changes.insert(
@@ -1610,10 +1600,6 @@ fn apply_patch_manual_flow_snapshot() {
.pop() .pop()
.expect("approved patch cell"); .expect("approved patch cell");
assert_snapshot!(
"apply_patch_manual_flow_history_proposed",
lines_to_single_string(&proposed_lines)
);
assert_snapshot!( assert_snapshot!(
"apply_patch_manual_flow_history_approved", "apply_patch_manual_flow_history_approved",
lines_to_single_string(&approved_lines) lines_to_single_string(&approved_lines)
@@ -1803,24 +1789,42 @@ fn apply_patch_request_shows_diff_summary() {
}), }),
}); });
// Drain history insertions and verify the diff summary is present // No history entries yet; the modal should contain the diff summary
let cells = drain_insert_history(&mut rx); let cells = drain_insert_history(&mut rx);
assert!( assert!(
!cells.is_empty(), cells.is_empty(),
"expected a history cell with the proposed patch summary" "expected approval request to render via modal instead of history"
);
let blob = lines_to_single_string(cells.last().unwrap());
// Header should summarize totals
assert!(
blob.contains("Proposed Change README.md (+2 -0)"),
"missing or incorrect diff header: {blob:?}"
); );
// Per-file summary line should include the file path and counts let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_header = false;
let mut saw_line1 = false;
let mut saw_line2 = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("README.md (+2 -0)") {
saw_header = true;
}
if row.contains("+line one") {
saw_line1 = true;
}
if row.contains("+line two") {
saw_line2 = true;
}
if saw_header && saw_line1 && saw_line2 {
break;
}
}
assert!(saw_header, "expected modal to show diff header with totals");
assert!( assert!(
blob.contains("README.md"), saw_line1 && saw_line2,
"missing per-file diff summary: {blob:?}" "expected modal to show per-line diff summary"
); );
} }

View File

@@ -1,16 +1,20 @@
use diffy::Hunk; use diffy::Hunk;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::style::Modifier; use ratatui::style::Modifier;
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::style::Stylize; use ratatui::style::Stylize;
use ratatui::text::Line as RtLine; use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan; use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::path::PathBuf; use std::path::PathBuf;
use crate::exec_command::relativize_to_home; use crate::exec_command::relativize_to_home;
use crate::history_cell::PatchEventType; use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use codex_core::git_info::get_git_repo_root; use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::FileChange; use codex_core::protocol::FileChange;
@@ -23,24 +27,57 @@ enum DiffLineType {
Context, Context,
} }
pub struct DiffSummary {
changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf,
}
impl DiffSummary {
pub fn new(changes: HashMap<PathBuf, FileChange>, cwd: PathBuf) -> Self {
Self { changes, cwd }
}
}
impl Renderable for FileChange {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut lines = vec![];
render_change(self, &mut lines, area.width as usize);
Paragraph::new(lines).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let mut lines = vec![];
render_change(self, &mut lines, width as usize);
lines.len() as u16
}
}
impl From<DiffSummary> for Box<dyn Renderable> {
fn from(val: DiffSummary) -> Self {
let mut rows: Vec<Box<dyn Renderable>> = vec![];
for (i, row) in collect_rows(&val.changes).into_iter().enumerate() {
if i > 0 {
rows.push(Box::new(RtLine::from("")));
}
let mut path = RtLine::from(display_path_for(&row.path, &val.cwd));
path.push_span(" ");
path.extend(render_line_count_summary(row.added, row.removed));
rows.push(Box::new(path));
rows.push(Box::new(row.change));
}
Box::new(ColumnRenderable::new(rows))
}
}
pub(crate) fn create_diff_summary( pub(crate) fn create_diff_summary(
changes: &HashMap<PathBuf, FileChange>, changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
cwd: &Path, cwd: &Path,
wrap_cols: usize, wrap_cols: usize,
) -> Vec<RtLine<'static>> { ) -> Vec<RtLine<'static>> {
let rows = collect_rows(changes); let rows = collect_rows(changes);
let header_kind = match event_type { render_changes_block(rows, wrap_cols, cwd)
PatchEventType::ApplyBegin { auto_approved } => {
if auto_approved {
HeaderKind::Edited
} else {
HeaderKind::ChangeApproved
}
}
PatchEventType::ApprovalRequest => HeaderKind::ProposedChange,
};
render_changes_block(rows, wrap_cols, header_kind, cwd)
} }
// Shared row for per-file presentation // Shared row for per-file presentation
@@ -81,30 +118,18 @@ fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
rows rows
} }
enum HeaderKind { fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
ProposedChange, let mut spans = Vec::new();
Edited, spans.push("(".into());
ChangeApproved, spans.push(format!("+{added}").green());
spans.push(" ".into());
spans.push(format!("-{removed}").red());
spans.push(")".into());
spans
} }
fn render_changes_block( fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtLine<'static>> {
rows: Vec<Row>,
wrap_cols: usize,
header_kind: HeaderKind,
cwd: &Path,
) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new(); let mut out: Vec<RtLine<'static>> = Vec::new();
let term_cols = wrap_cols;
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
spans.push("(".into());
spans.push(format!("+{added}").green());
spans.push(" ".into());
spans.push(format!("-{removed}").red());
spans.push(")".into());
spans
}
let render_path = |row: &Row| -> Vec<RtSpan<'static>> { let render_path = |row: &Row| -> Vec<RtSpan<'static>> {
let mut spans = Vec::new(); let mut spans = Vec::new();
@@ -121,66 +146,31 @@ fn render_changes_block(
let file_count = rows.len(); let file_count = rows.len();
let noun = if file_count == 1 { "file" } else { "files" }; let noun = if file_count == 1 { "file" } else { "files" };
let mut header_spans: Vec<RtSpan<'static>> = vec!["".into()]; let mut header_spans: Vec<RtSpan<'static>> = vec!["".into()];
match header_kind { if let [row] = &rows[..] {
HeaderKind::ProposedChange => { let verb = match &row.change {
header_spans.push("Proposed Change".bold()); FileChange::Add { .. } => "Added",
if let [row] = &rows[..] { FileChange::Delete { .. } => "Deleted",
header_spans.push(" ".into()); _ => "Edited",
header_spans.extend(render_path(row)); };
header_spans.push(" ".into()); header_spans.push(verb.bold());
header_spans.extend(render_line_count_summary(row.added, row.removed)); header_spans.push(" ".into());
} else { header_spans.extend(render_path(row));
header_spans.push(format!(" to {file_count} {noun} ").into()); header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(total_added, total_removed)); header_spans.extend(render_line_count_summary(row.added, row.removed));
} } else {
} header_spans.push("Edited".bold());
HeaderKind::Edited => { header_spans.push(format!(" {file_count} {noun} ").into());
if let [row] = &rows[..] { header_spans.extend(render_line_count_summary(total_added, total_removed));
let verb = match &row.change {
FileChange::Add { .. } => "Added",
FileChange::Delete { .. } => "Deleted",
_ => "Edited",
};
header_spans.push(verb.bold());
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push("Edited".bold());
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
HeaderKind::ChangeApproved => {
header_spans.push("Change Approved".bold());
if let [row] = &rows[..] {
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
} }
out.push(RtLine::from(header_spans)); out.push(RtLine::from(header_spans));
// For Change Approved, we only show the header summary and no per-file/diff details.
if matches!(header_kind, HeaderKind::ChangeApproved) {
return out;
}
for (idx, r) in rows.into_iter().enumerate() { for (idx, r) in rows.into_iter().enumerate() {
// Insert a blank separator between file chunks (except before the first) // Insert a blank separator between file chunks (except before the first)
if idx > 0 { if idx > 0 {
out.push("".into()); out.push("".into());
} }
// File header line (skip when single-file header already shows the name) // File header line (skip when single-file header already shows the name)
let skip_file_header = let skip_file_header = file_count == 1;
matches!(header_kind, HeaderKind::ProposedChange | HeaderKind::Edited)
&& file_count == 1;
if !skip_file_header { if !skip_file_header {
let mut header: Vec<RtSpan<'static>> = Vec::new(); let mut header: Vec<RtSpan<'static>> = Vec::new();
header.push("".dim()); header.push("".dim());
@@ -190,71 +180,77 @@ fn render_changes_block(
out.push(RtLine::from(header)); out.push(RtLine::from(header));
} }
match r.change { render_change(&r.change, &mut out, wrap_cols);
FileChange::Add { content } => { }
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
term_cols,
));
}
}
FileChange::Delete { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
term_cols,
));
}
}
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(&unified_diff) {
let mut is_first_hunk = true;
for h in patch.hunks() {
if !is_first_hunk {
out.push(RtLine::from(vec![" ".into(), "".dim()]));
}
is_first_hunk = false;
let mut old_ln = h.old_range().start(); out
let mut new_ln = h.new_range().start(); }
for l in h.lines() {
match l { fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usize) {
diffy::Line::Insert(text) => { match change {
let s = text.trim_end_matches('\n'); FileChange::Add { content } => {
out.extend(push_wrapped_diff_line( for (i, raw) in content.lines().enumerate() {
new_ln, out.extend(push_wrapped_diff_line(
DiffLineType::Insert, i + 1,
s, DiffLineType::Insert,
term_cols, raw,
)); width,
new_ln += 1; ));
} }
diffy::Line::Delete(text) => { }
let s = text.trim_end_matches('\n'); FileChange::Delete { content } => {
out.extend(push_wrapped_diff_line( for (i, raw) in content.lines().enumerate() {
old_ln, out.extend(push_wrapped_diff_line(
DiffLineType::Delete, i + 1,
s, DiffLineType::Delete,
term_cols, raw,
)); width,
old_ln += 1; ));
} }
diffy::Line::Context(text) => { }
let s = text.trim_end_matches('\n'); FileChange::Update { unified_diff, .. } => {
out.extend(push_wrapped_diff_line( if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
new_ln, let mut is_first_hunk = true;
DiffLineType::Context, for h in patch.hunks() {
s, if !is_first_hunk {
term_cols, out.push(RtLine::from(vec![" ".into(), "".dim()]));
)); }
old_ln += 1; is_first_hunk = false;
new_ln += 1;
} let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for l in h.lines() {
match l {
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
width,
));
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
width,
));
old_ln += 1;
}
diffy::Line::Context(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
width,
));
old_ln += 1;
new_ln += 1;
} }
} }
} }
@@ -262,8 +258,6 @@ fn render_changes_block(
} }
} }
} }
out
} }
pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String { pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
@@ -300,7 +294,7 @@ fn push_wrapped_diff_line(
line_number: usize, line_number: usize,
kind: DiffLineType, kind: DiffLineType,
text: &str, text: &str,
term_cols: usize, width: usize,
) -> Vec<RtLine<'static>> { ) -> Vec<RtLine<'static>> {
let indent = " "; let indent = " ";
let ln_str = line_number.to_string(); let ln_str = line_number.to_string();
@@ -325,7 +319,7 @@ fn push_wrapped_diff_line(
// Fit the content for the current terminal row: // Fit the content for the current terminal row:
// compute how many columns are available after the prefix, then split // compute how many columns are available after the prefix, then split
// at a UTF-8 character boundary so this row's chunk fits exactly. // at a UTF-8 character boundary so this row's chunk fits exactly.
let available_content_cols = term_cols.saturating_sub(prefix_cols + 1).max(1); let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
let split_at_byte_index = remaining_text let split_at_byte_index = remaining_text
.char_indices() .char_indices()
.nth(available_content_cols) .nth(available_content_cols)
@@ -385,11 +379,8 @@ mod tests {
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap; use ratatui::widgets::Wrap;
fn diff_summary_for_tests( fn diff_summary_for_tests(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
changes: &HashMap<PathBuf, FileChange>, create_diff_summary(changes, &PathBuf::from("/"), 80)
event_type: PatchEventType,
) -> Vec<RtLine<'static>> {
create_diff_summary(changes, event_type, &PathBuf::from("/"), 80)
} }
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) { fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
@@ -421,42 +412,6 @@ mod tests {
assert_snapshot!(name, text); assert_snapshot!(name, text);
} }
#[test]
fn ui_snapshot_add_details() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Add {
content: "first line\nsecond line\n".to_string(),
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("add_details", lines, 80, 10);
}
#[test]
fn ui_snapshot_update_details_with_rename() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "line one\nline two\nline three\n";
let modified = "line one\nline two changed\nline three\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("src/lib.rs"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("src/lib_new.rs")),
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("update_details_with_rename", lines, 80, 12);
}
#[test] #[test]
fn ui_snapshot_wrap_behavior_insert() { fn ui_snapshot_wrap_behavior_insert() {
// Narrow width to force wrapping within our diff line rendering // Narrow width to force wrapping within our diff line rendering
@@ -469,71 +424,6 @@ mod tests {
snapshot_lines("wrap_behavior_insert", lines, 90, 8); snapshot_lines("wrap_behavior_insert", lines, 90, 8);
} }
#[test]
fn ui_snapshot_single_line_replacement_counts() {
// Reproduce: one deleted line replaced by one inserted line, no extra context
let original = "# Codex CLI (Rust Implementation)\n";
let modified = "# Codex CLI (Rust Implementation) banana\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("single_line_replacement_counts", lines, 80, 8);
}
#[test]
fn ui_snapshot_blank_context_line() {
// Ensure a hunk that includes a blank context line at the beginning is rendered visibly
let original = "\nY\n";
let modified = "\nY changed\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("blank_context_line", lines, 80, 10);
}
#[test]
fn ui_snapshot_vertical_ellipsis_between_hunks() {
// Create a patch with two separate hunks to ensure we render the vertical ellipsis (⋮)
let original =
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n";
let modified = "line 1\nline two changed\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline nine changed\nline 10\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
// Height is large enough to show both hunks and the separator
snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16);
}
#[test] #[test]
fn ui_snapshot_apply_update_block() { fn ui_snapshot_apply_update_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new(); let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
@@ -549,12 +439,8 @@ mod tests {
}, },
); );
for (name, auto_approved) in [ for name in ["apply_update_block", "apply_update_block_manual"] {
("apply_update_block", true), let lines = diff_summary_for_tests(&changes);
("apply_update_block_manual", false),
] {
let lines =
diff_summary_for_tests(&changes, PatchEventType::ApplyBegin { auto_approved });
snapshot_lines(name, lines, 80, 12); snapshot_lines(name, lines, 80, 12);
} }
@@ -575,12 +461,7 @@ mod tests {
}, },
); );
let lines = diff_summary_for_tests( let lines = diff_summary_for_tests(&changes);
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_update_with_rename_block", lines, 80, 12); snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
} }
@@ -608,12 +489,7 @@ mod tests {
}, },
); );
let lines = diff_summary_for_tests( let lines = diff_summary_for_tests(&changes);
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_multiple_files_block", lines, 80, 14); snapshot_lines("apply_multiple_files_block", lines, 80, 14);
} }
@@ -628,12 +504,7 @@ mod tests {
}, },
); );
let lines = diff_summary_for_tests( let lines = diff_summary_for_tests(&changes);
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_add_block", lines, 80, 10); snapshot_lines("apply_add_block", lines, 80, 10);
} }
@@ -652,12 +523,7 @@ mod tests {
}, },
); );
let lines = diff_summary_for_tests( let lines = diff_summary_for_tests(&changes);
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
// Cleanup best-effort; rendering has already read the file // Cleanup best-effort; rendering has already read the file
let _ = std::fs::remove_file(&tmp_path); let _ = std::fs::remove_file(&tmp_path);
@@ -681,14 +547,7 @@ mod tests {
}, },
); );
let lines = create_diff_summary( let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72);
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&PathBuf::from("/"),
72,
);
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap. // Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12); snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
@@ -711,14 +570,7 @@ mod tests {
}, },
); );
let mut lines = create_diff_summary( let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&PathBuf::from("/"),
28,
);
// Drop the combined header for this text-only snapshot // Drop the combined header for this text-only snapshot
if !lines.is_empty() { if !lines.is_empty() {
lines.remove(0); lines.remove(0);
@@ -745,14 +597,7 @@ mod tests {
}, },
); );
let lines = create_diff_summary( let lines = create_diff_summary(&changes, &cwd, 80);
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&cwd,
80,
);
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10); snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
} }

View File

@@ -5,7 +5,6 @@ use crate::exec_cell::TOOL_CALL_MAX_LINES;
use crate::exec_cell::output_lines; use crate::exec_cell::output_lines;
use crate::exec_cell::spinner; use crate::exec_cell::spinner;
use crate::exec_command::relativize_to_home; use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::MarkdownCitationContext; use crate::markdown::MarkdownCitationContext;
use crate::markdown::append_markdown; use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static; use crate::render::line_utils::line_to_static;
@@ -50,12 +49,6 @@ use std::time::Instant;
use tracing::error; use tracing::error;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
#[derive(Clone, Debug)]
pub(crate) enum PatchEventType {
ApprovalRequest,
ApplyBegin { auto_approved: bool },
}
/// Represents an event to display in the conversation history. Returns its /// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a /// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list. /// scrollable list.
@@ -277,19 +270,13 @@ pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct PatchHistoryCell { pub(crate) struct PatchHistoryCell {
event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>, changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf, cwd: PathBuf,
} }
impl HistoryCell for PatchHistoryCell { impl HistoryCell for PatchHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> { fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
create_diff_summary( create_diff_summary(&self.changes, &self.cwd, width as usize)
&self.changes,
self.event_type.clone(),
&self.cwd,
width as usize,
)
} }
} }
@@ -1016,12 +1003,10 @@ impl HistoryCell for PlanUpdateCell {
/// a proposed patch. The summary lines should already be formatted (e.g. /// a proposed patch. The summary lines should already be formatted (e.g.
/// "A path/to/file.rs"). /// "A path/to/file.rs").
pub(crate) fn new_patch_event( pub(crate) fn new_patch_event(
event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>, changes: HashMap<PathBuf, FileChange>,
cwd: &Path, cwd: &Path,
) -> PatchHistoryCell { ) -> PatchHistoryCell {
PatchHistoryCell { PatchHistoryCell {
event_type,
changes, changes,
cwd: cwd.to_path_buf(), cwd: cwd.to_path_buf(),
} }
@@ -1052,27 +1037,6 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
PlainHistoryCell { lines } PlainHistoryCell { lines }
} }
/// Create a new history cell for a proposed command approval.
/// Renders a header and the command preview similar to how proposed patches
/// show a header and summary.
pub(crate) fn new_proposed_command(command: &[String]) -> PlainHistoryCell {
let cmd = strip_bash_lc_and_escape(command);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec!["".dim(), "Proposed Command".bold()]));
let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd);
let initial_prefix: Span<'static> = "".dim();
let subsequent_prefix: Span<'static> = " ".into();
lines.extend(prefix_lines(
highlighted_lines,
initial_prefix,
subsequent_prefix,
));
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_block( pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String, full_reasoning_buffer: String,
config: &Config, config: &Config,

View File

@@ -3,13 +3,14 @@ use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable;
use crate::tui; use crate::tui;
use crate::tui::TuiEvent; use crate::tui::TuiEvent;
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind; use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::buffer::Cell;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Color; use ratatui::style::Color;
use ratatui::style::Style; use ratatui::style::Style;
@@ -22,6 +23,7 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
pub(crate) enum Overlay { pub(crate) enum Overlay {
Transcript(TranscriptOverlay), Transcript(TranscriptOverlay),
@@ -33,10 +35,17 @@ impl Overlay {
Self::Transcript(TranscriptOverlay::new(cells)) 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)) 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<()> { pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match self { match self {
Overlay::Transcript(o) => o.handle_event(tui, event), 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. /// Generic widget for rendering a pager view.
struct PagerView { struct PagerView {
texts: Vec<Text<'static>>, renderables: Vec<Box<dyn Renderable>>,
scroll_offset: usize, scroll_offset: usize,
title: String, title: String,
wrap_cache: Option<WrapCache>,
last_content_height: Option<usize>, last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
/// If set, on next render ensure this chunk is visible. /// If set, on next render ensure this chunk is visible.
pending_scroll_chunk: Option<usize>, pending_scroll_chunk: Option<usize>,
} }
impl PagerView { 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 { Self {
texts, renderables,
scroll_offset, scroll_offset,
title, title,
wrap_cache: None,
last_content_height: None, last_content_height: None,
last_rendered_height: None,
pending_scroll_chunk: 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) { fn render(&mut self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf); Clear.render(area, buf);
self.render_header(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.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, // 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. // satisfy it now that wrapping is up to date for this width.
if let (Some(idx), Some(cache)) = if let Some(idx) = self.pending_scroll_chunk.take() {
(self.pending_scroll_chunk.take(), self.wrap_cache.as_ref()) self.ensure_chunk_visible(idx, content_area);
&& let Some(range) = cache.chunk_ranges.get(idx).cloned()
{
self.ensure_range_visible(range, content_area.height as usize, cache.wrapped.len());
} }
// 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 self.scroll_offset = self
.scroll_offset .scroll_offset
.min(wrapped_len.saturating_sub(content_area.height as usize)); .min(content_height.saturating_sub(content_area.height as usize));
let start = self.scroll_offset;
let end = (start + content_area.height as usize).min(wrapped_len);
let wrapped = self.cached(); self.render_content(content_area, buf);
let page = &wrapped[start..end];
self.render_content_page_prepared(content_area, buf, page); self.render_bottom_bar(area, content_area, buf, content_height);
self.render_bottom_bar(area, content_area, buf, wrapped);
} }
fn render_header(&self, area: Rect, buf: &mut Buffer) { fn render_header(&self, area: Rect, buf: &mut Buffer) {
@@ -139,20 +144,38 @@ impl PagerView {
header.dim().render_ref(area, buf); 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>]) { for y in drawn_bottom..area.bottom() {
Clear.render(area, buf); if area.width == 0 {
Paragraph::new(page.to_vec()).render_ref(area, buf); break;
}
let visible = page.len(); buf[(area.x, y)] = Cell::from('~');
if visible < area.height as usize { for x in area.x + 1..area.right() {
for i in 0..(area.height as usize - visible) { buf[(x, y)] = Cell::from(' ');
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);
} }
} }
} }
@@ -162,7 +185,7 @@ impl PagerView {
full_area: Rect, full_area: Rect,
content_area: Rect, content_area: Rect,
buf: &mut Buffer, buf: &mut Buffer,
wrapped: &[Line<'static>], total_len: usize,
) { ) {
let sep_y = content_area.bottom(); let sep_y = content_area.bottom();
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1); 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)) Span::from("".repeat(sep_rect.width as usize))
.dim() .dim()
.render_ref(sep_rect, buf); .render_ref(sep_rect, buf);
let percent = if wrapped.is_empty() { let percent = if total_len == 0 {
100 100
} else { } 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 { if max_scroll == 0 {
100 100
} else { } else {
@@ -210,7 +233,7 @@ impl PagerView {
kind: KeyEventKind::Press | KeyEventKind::Repeat, 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); self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
} }
KeyEvent { KeyEvent {
@@ -218,7 +241,7 @@ impl PagerView {
kind: KeyEventKind::Press | KeyEventKind::Repeat, 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); self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
} }
KeyEvent { KeyEvent {
@@ -248,7 +271,7 @@ impl PagerView {
self.last_content_height = Some(height as usize); 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; let mut area = area;
area.y = area.y.saturating_add(1); area.y = area.y.saturating_add(1);
area.height = area.height.saturating_sub(2); 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 { 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 { fn is_scrolled_to_bottom(&self) -> bool {
if self.scroll_offset == usize::MAX { if self.scroll_offset == usize::MAX {
return true; return true;
} }
let Some(cache) = &self.wrap_cache else {
return false;
};
let Some(height) = self.last_content_height else { let Some(height) = self.last_content_height else {
return false; return false;
}; };
if cache.wrapped.is_empty() { if self.renderables.is_empty() {
return true; return true;
} }
let visible = height.min(cache.wrapped.len()); let Some(total_height) = self.last_rendered_height else {
let max_scroll = cache.wrapped.len().saturating_sub(visible); return false;
};
if total_height <= height {
return true;
}
let max_scroll = total_height.saturating_sub(height);
self.scroll_offset >= max_scroll self.scroll_offset >= max_scroll
} }
@@ -325,32 +305,57 @@ impl PagerView {
self.pending_scroll_chunk = Some(chunk_index); self.pending_scroll_chunk = Some(chunk_index);
} }
fn ensure_range_visible( fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
&mut self, if area.height == 0 || idx >= self.renderables.len() {
range: std::ops::Range<usize>,
viewport_height: usize,
total_wrapped: usize,
) {
if viewport_height == 0 || total_wrapped == 0 {
return; return;
} }
let first = range.start.min(total_wrapped.saturating_sub(1)); let first = self
let last = range .renderables
.end .iter()
.saturating_sub(1) .take(idx)
.min(total_wrapped.saturating_sub(1)); .map(|r| r.desired_height(area.width) as usize)
let current_top = self.scroll_offset.min(total_wrapped.saturating_sub(1)); .sum();
let current_bottom = current_top.saturating_add(viewport_height.saturating_sub(1)); 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 { if first < current_top {
self.scroll_offset = first; self.scroll_offset = first;
} else if last > current_bottom { } else if last > current_bottom {
// Scroll just enough so that 'last' is visible at the bottom self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
self.scroll_offset = last.saturating_sub(viewport_height.saturating_sub(1));
} }
} }
} }
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 { pub(crate) struct TranscriptOverlay {
view: PagerView, view: PagerView,
cells: Vec<Arc<dyn HistoryCell>>, cells: Vec<Arc<dyn HistoryCell>>,
@@ -375,8 +380,8 @@ impl TranscriptOverlay {
fn render_cells_to_texts( fn render_cells_to_texts(
cells: &[Arc<dyn HistoryCell>], cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>, highlight_cell: Option<usize>,
) -> Vec<Text<'static>> { ) -> Vec<Box<dyn Renderable>> {
let mut texts: Vec<Text<'static>> = Vec::new(); let mut texts: Vec<Box<dyn Renderable>> = Vec::new();
let mut first = true; let mut first = true;
for (idx, cell) in cells.iter().enumerate() { for (idx, cell) in cells.iter().enumerate() {
let mut lines: Vec<Line<'static>> = Vec::new(); let mut lines: Vec<Line<'static>> = Vec::new();
@@ -392,7 +397,9 @@ impl TranscriptOverlay {
cell.transcript_lines() cell.transcript_lines()
}; };
lines.extend(cell_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; first = false;
} }
texts texts
@@ -406,9 +413,10 @@ impl TranscriptOverlay {
lines.push(Line::from("")); lines.push(Line::from(""));
} }
lines.extend(cell.transcript_lines()); 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.cells.push(cell);
self.view.wrap_cache = None;
if follow_bottom { if follow_bottom {
self.view.scroll_offset = usize::MAX; self.view.scroll_offset = usize::MAX;
} }
@@ -416,8 +424,7 @@ impl TranscriptOverlay {
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) { pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell; self.highlight_cell = cell;
self.view.wrap_cache = None; self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
self.view.texts = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
if let Some(idx) = self.highlight_cell { if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx); self.view.scroll_chunk_into_view(idx);
} }
@@ -490,8 +497,17 @@ pub(crate) struct StaticOverlay {
impl StaticOverlay { impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self { 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 { Self {
view: PagerView::new(vec![Text::from(lines)], title, 0), view: PagerView::new(renderables, title, 0),
is_done: false, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -558,12 +601,12 @@ mod tests {
use crate::exec_cell::CommandOutput; use crate::exec_cell::CommandOutput;
use crate::history_cell::HistoryCell; use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::history_cell::new_patch_event; use crate::history_cell::new_patch_event;
use codex_core::protocol::FileChange; use codex_core::protocol::FileChange;
use codex_protocol::parse_command::ParsedCommand; use codex_protocol::parse_command::ParsedCommand;
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::TestBackend; use ratatui::backend::TestBackend;
use ratatui::text::Text;
#[derive(Debug)] #[derive(Debug)]
struct TestCell { 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] #[test]
fn edit_prev_hint_is_visible() { fn edit_prev_hint_is_visible() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell { let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
@@ -657,11 +709,7 @@ mod tests {
content: "hello\nworld\n".to_string(), content: "hello\nworld\n".to_string(),
}, },
); );
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event( let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(approval_changes, &cwd));
PatchEventType::ApprovalRequest,
approval_changes,
&cwd,
));
cells.push(approval_cell); cells.push(approval_cell);
let mut apply_changes = HashMap::new(); let mut apply_changes = HashMap::new();
@@ -671,13 +719,7 @@ mod tests {
content: "hello\nworld\n".to_string(), content: "hello\nworld\n".to_string(),
}, },
); );
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event( let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(apply_changes, &cwd));
PatchEventType::ApplyBegin {
auto_approved: false,
},
apply_changes,
&cwd,
));
cells.push(apply_begin_cell); cells.push(apply_begin_cell);
let apply_end_cell: Arc<dyn HistoryCell> = let apply_end_cell: Arc<dyn HistoryCell> =
@@ -711,7 +753,6 @@ mod tests {
overlay.render(area, &mut buf); overlay.render(area, &mut buf);
overlay.view.scroll_offset = 0; overlay.view.scroll_offset = 0;
overlay.view.wrap_cache = None;
overlay.render(area, &mut buf); overlay.render(area, &mut buf);
let snapshot = buffer_to_text(&buf, area); let snapshot = buffer_to_text(&buf, area);
@@ -783,54 +824,89 @@ mod tests {
} }
#[test] #[test]
fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() { fn pager_view_content_height_counts_renderables() {
let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output."; let pv = PagerView::new(
let mut pv = PagerView::new( vec![paragraph_block("a", 2), paragraph_block("b", 3)],
vec![Text::from(vec![long.into()]), Text::from(vec![long.into()])],
"T".to_string(), "T".to_string(),
0, 0,
); );
// Build cache at width 24 assert_eq!(pv.content_height(80), 5);
pv.ensure_wrapped(24); }
let w1 = pv.cached();
assert!(!w1.is_empty(), "expected wrapped output to be non-empty");
let ptr1 = w1.as_ptr();
// Re-run with same width: cache should be reused (pointer stability heuristic) #[test]
pv.ensure_wrapped(24); fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
let w2 = pv.cached(); let mut pv = PagerView::new(
let ptr2 = w2.as_ptr(); vec![
assert_eq!(ptr1, ptr2, "cache should not rebuild for unchanged width"); 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 pv.scroll_offset = 0;
// Drop immutable borrow before mutating let content_area = pv.content_area(area);
let prev_len = w2.len(); pv.ensure_chunk_visible(2, content_area);
pv.ensure_wrapped(36);
let w3 = pv.cached(); let mut buf = Buffer::empty(area);
assert_ne!( pv.render(area, &mut buf);
prev_len, let rendered = buffer_to_text(&buf, area);
w3.len(),
"wrapped length should change on width change" 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] #[test]
fn pager_wrap_cache_invalidates_on_append() { fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
let long = "Another long line for wrapping behavior verification."; let mut pv = PagerView::new(
let mut pv = PagerView::new(vec![Text::from(vec![long.into()])], "T".to_string(), 0); vec![
pv.ensure_wrapped(28); paragraph_block("a", 2),
let w1 = pv.cached(); paragraph_block("b", 3),
let len1 = w1.len(); 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!( assert!(
w2.len() >= len1, !pv.is_scrolled_to_bottom(),
"wrapped length should grow or stay same after append" "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"
); );
} }
} }

View File

@@ -1,2 +1,47 @@
use ratatui::layout::Rect;
pub mod highlight; pub mod highlight;
pub mod line_utils; pub mod line_utils;
pub mod renderable;
pub struct Insets {
pub left: u16,
pub top: u16,
pub right: u16,
pub bottom: u16,
}
impl Insets {
pub fn tlbr(top: u16, left: u16, bottom: u16, right: u16) -> Self {
Self {
top,
left,
bottom,
right,
}
}
pub fn vh(v: u16, h: u16) -> Self {
Self {
top: v,
left: h,
bottom: v,
right: h,
}
}
}
pub trait RectExt {
fn inset(&self, insets: Insets) -> Rect;
}
impl RectExt for Rect {
fn inset(&self, insets: Insets) -> Rect {
Rect {
x: self.x + insets.left,
y: self.y + insets.top,
width: self.width - insets.left - insets.right,
height: self.height - insets.top - insets.bottom,
}
}
}

View File

@@ -0,0 +1,102 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
pub trait Renderable {
fn render(&self, area: Rect, buf: &mut Buffer);
fn desired_height(&self, width: u16) -> u16;
}
impl Renderable for () {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
impl Renderable for &str {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
impl Renderable for String {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
impl<'a> Renderable for Line<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
WidgetRef::render_ref(self, area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
impl<'a> Renderable for Paragraph<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.line_count(width) as u16
}
}
impl<R: Renderable> Renderable for Option<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
if let Some(renderable) = self {
renderable.render(area, buf);
}
}
fn desired_height(&self, width: u16) -> u16 {
if let Some(renderable) = self {
renderable.desired_height(width)
} else {
0
}
}
}
pub struct ColumnRenderable {
children: Vec<Box<dyn Renderable>>,
}
impl Renderable for ColumnRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut y = area.y;
for child in &self.children {
let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width))
.intersection(area);
if !child_area.is_empty() {
child.render(child_area, buf);
}
y += child_area.height;
}
}
fn desired_height(&self, width: u16) -> u16 {
self.children
.iter()
.map(|child| child.desired_height(width))
.sum()
}
}
impl ColumnRenderable {
pub fn new(children: impl IntoIterator<Item = Box<dyn Renderable>>) -> Self {
Self {
children: children.into_iter().collect(),
}
}
}

View File

@@ -2,11 +2,11 @@
source: tui/src/diff_render.rs source: tui/src/diff_render.rs
expression: terminal.backend() expression: terminal.backend()
--- ---
"• Change Approved example.txt (+1 -1) " "• Edited example.txt (+1 -1) "
" " " 1 line one "
" " " 2 -line two "
" " " 2 +line two changed "
" " " 3 line three "
" " " "
" " " "
" " " "

View File

@@ -1,16 +1,15 @@
--- ---
source: tui/src/pager_overlay.rs source: tui/src/pager_overlay.rs
assertion_line: 721
expression: snapshot expression: snapshot
--- ---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
Proposed Change foo.txt (+2 -0) Added foo.txt (+2 -0)
1 +hello 1 +hello
2 +world 2 +world
Change Approved foo.txt (+2 -0) Added foo.txt (+2 -0)
1 +hello
✓ Patch applied 2 +world
─────────────────────────────────────────────────────────────────────────── 0% ─ ─────────────────────────────────────────────────────────────────────────── 0% ─
↑/↓ scroll PgUp/PgDn page Home/End jump ↑/↓ scroll PgUp/PgDn page Home/End jump
q quit Esc edit prev q quit Esc edit prev

View File

@@ -17,7 +17,6 @@ use crate::app_event_sender::AppEventSender;
use crate::key_hint; use crate::key_hint;
use crate::shimmer::shimmer_spans; use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester; use crate::tui::FrameRequester;
use crate::ui_consts::LIVE_PREFIX_COLS;
pub(crate) struct StatusIndicatorWidget { pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working"). /// Animated header text (defaults to "Working").
@@ -160,7 +159,7 @@ impl WidgetRef for StatusIndicatorWidget {
let pretty_elapsed = fmt_elapsed_compact(elapsed); let pretty_elapsed = fmt_elapsed_compact(elapsed);
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let mut spans = vec![" ".repeat(LIVE_PREFIX_COLS as usize).into()]; let mut spans = vec![" ".into()];
spans.extend(shimmer_spans(&self.header)); spans.extend(shimmer_spans(&self.header));
spans.extend(vec![ spans.extend(vec![
" ".into(), " ".into(),