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:
@@ -1,10 +1,14 @@
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
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::history_cell::HistoryCell;
|
||||
use crate::pager_overlay::Overlay;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::resume_picker::ResumeSelection;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
@@ -292,7 +296,7 @@ impl App {
|
||||
} else {
|
||||
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,
|
||||
"D I F F".to_string(),
|
||||
));
|
||||
@@ -324,12 +328,18 @@ impl App {
|
||||
Ok(()) => {
|
||||
if let Some(profile) = profile {
|
||||
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,
|
||||
);
|
||||
} else {
|
||||
self.chat_widget
|
||||
.add_info_message(format!("Model changed to {model}"), None);
|
||||
self.chat_widget.add_info_message(
|
||||
format!(
|
||||
"Model changed to {model}{reasoning_effort}",
|
||||
reasoning_effort =
|
||||
effort.map(|e| format!(" {e}")).unwrap_or_default()
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -363,6 +373,25 @@ impl App {
|
||||
AppEvent::OpenReviewCustomPrompt => {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_core::protocol::ConversationPathResponseEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::protocol::AskForApproval;
|
||||
@@ -76,4 +77,7 @@ pub(crate) enum AppEvent {
|
||||
|
||||
/// Open the custom prompt option from the review popup.
|
||||
OpenReviewCustomPrompt,
|
||||
|
||||
/// Open the approval popup.
|
||||
FullScreenApprovalRequest(ApprovalRequest),
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::BottomPaneView;
|
||||
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::SelectionItem;
|
||||
use crate::bottom_pane::list_selection_view::SelectionViewParams;
|
||||
use crate::diff_render::DiffSummary;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
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 codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -22,8 +27,11 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum ApprovalRequest {
|
||||
Exec {
|
||||
id: String,
|
||||
@@ -33,13 +41,15 @@ pub(crate) enum ApprovalRequest {
|
||||
ApplyPatch {
|
||||
id: 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.
|
||||
pub(crate) struct ApprovalOverlay {
|
||||
current: Option<ApprovalRequestState>,
|
||||
current_request: Option<ApprovalRequest>,
|
||||
current_variant: Option<ApprovalVariant>,
|
||||
queue: Vec<ApprovalRequest>,
|
||||
app_event_tx: AppEventSender,
|
||||
list: ListSelectionView,
|
||||
@@ -51,23 +61,16 @@ pub(crate) struct ApprovalOverlay {
|
||||
impl ApprovalOverlay {
|
||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
let mut view = Self {
|
||||
current: Some(ApprovalRequestState::from(request)),
|
||||
current_request: None,
|
||||
current_variant: None,
|
||||
queue: Vec::new(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
list: ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: String::new(),
|
||||
..Default::default()
|
||||
},
|
||||
app_event_tx,
|
||||
),
|
||||
list: ListSelectionView::new(Default::default(), app_event_tx),
|
||||
options: Vec::new(),
|
||||
current_complete: false,
|
||||
done: false,
|
||||
};
|
||||
let (options, params) = view.build_options();
|
||||
view.options = options;
|
||||
view.list = ListSelectionView::new(params, view.app_event_tx.clone());
|
||||
view.set_current(request);
|
||||
view
|
||||
}
|
||||
|
||||
@@ -76,28 +79,30 @@ impl ApprovalOverlay {
|
||||
}
|
||||
|
||||
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;
|
||||
let (options, params) = self.build_options();
|
||||
let (options, params) = Self::build_options(variant, header);
|
||||
self.options = options;
|
||||
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
|
||||
}
|
||||
|
||||
fn build_options(&self) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
||||
let Some(state) = self.current.as_ref() else {
|
||||
return (
|
||||
Vec::new(),
|
||||
SelectionViewParams {
|
||||
title: String::new(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
};
|
||||
let (options, title) = match &state.variant {
|
||||
fn build_options(
|
||||
variant: ApprovalVariant,
|
||||
header: Box<dyn Renderable>,
|
||||
) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
||||
let (options, title) = match &variant {
|
||||
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".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
|
||||
.iter()
|
||||
.map(|opt| SelectionItem {
|
||||
@@ -111,10 +116,9 @@ impl ApprovalOverlay {
|
||||
.collect();
|
||||
|
||||
let params = SelectionViewParams {
|
||||
title,
|
||||
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
|
||||
items,
|
||||
header: state.header.clone(),
|
||||
header,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -128,8 +132,8 @@ impl ApprovalOverlay {
|
||||
let Some(option) = self.options.get(actual_idx) else {
|
||||
return;
|
||||
};
|
||||
if let Some(state) = self.current.as_ref() {
|
||||
match (&state.variant, option.decision) {
|
||||
if let Some(variant) = self.current_variant.as_ref() {
|
||||
match (&variant, option.decision) {
|
||||
(ApprovalVariant::Exec { 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 {
|
||||
if key_event.kind != KeyEventKind::Press {
|
||||
return false;
|
||||
}
|
||||
let KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} = key_event
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) {
|
||||
return false;
|
||||
}
|
||||
let lower = c.to_ascii_lowercase();
|
||||
if let Some(idx) = self
|
||||
.options
|
||||
.iter()
|
||||
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
|
||||
{
|
||||
self.apply_selection(idx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press,
|
||||
code: KeyCode::Char('a'),
|
||||
modifiers,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if let Some(request) = self.current_request.as_ref() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
kind: KeyEventKind::Press,
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
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 } => {
|
||||
self.handle_exec_decision(id, command, ReviewDecision::Abort);
|
||||
}
|
||||
@@ -235,14 +252,6 @@ impl BottomPaneView for ApprovalOverlay {
|
||||
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(
|
||||
&mut self,
|
||||
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 {
|
||||
variant: ApprovalVariant,
|
||||
header: Vec<HeaderLine>,
|
||||
header: Box<dyn Renderable>,
|
||||
}
|
||||
|
||||
impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
@@ -269,63 +288,50 @@ impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
command,
|
||||
reason,
|
||||
} => {
|
||||
let mut header = Vec::new();
|
||||
let mut header: Vec<Line<'static>> = Vec::new();
|
||||
if let Some(reason) = reason
|
||||
&& !reason.is_empty()
|
||||
{
|
||||
header.push(HeaderLine::Text {
|
||||
text: reason,
|
||||
italic: true,
|
||||
});
|
||||
header.push(HeaderLine::Spacer);
|
||||
header.push(reason.italic().into());
|
||||
header.push(Line::from(""));
|
||||
}
|
||||
let command_snippet = exec_snippet(&command);
|
||||
if !command_snippet.is_empty() {
|
||||
header.push(HeaderLine::Text {
|
||||
text: format!("Command: {command_snippet}"),
|
||||
italic: false,
|
||||
});
|
||||
header.push(HeaderLine::Spacer);
|
||||
let full_cmd = strip_bash_lc_and_escape(&command);
|
||||
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
||||
if let Some(first) = full_cmd_lines.first_mut() {
|
||||
first.spans.insert(0, Span::from("$ "));
|
||||
}
|
||||
header.extend(full_cmd_lines);
|
||||
Self {
|
||||
variant: ApprovalVariant::Exec { id, command },
|
||||
header,
|
||||
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
|
||||
}
|
||||
}
|
||||
ApprovalRequest::ApplyPatch {
|
||||
id,
|
||||
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
|
||||
&& !reason.is_empty()
|
||||
{
|
||||
header.push(HeaderLine::Text {
|
||||
text: reason,
|
||||
italic: true,
|
||||
});
|
||||
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);
|
||||
header.push(Box::new(Line::from("")));
|
||||
header.push(Box::new(
|
||||
Paragraph::new(reason.italic()).wrap(Wrap { trim: false }),
|
||||
));
|
||||
}
|
||||
Self {
|
||||
variant: ApprovalVariant::ApplyPatch { id },
|
||||
header,
|
||||
header: Box::new(ColumnRenderable::new(header)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ApprovalVariant {
|
||||
Exec { id: String, command: Vec<String> },
|
||||
ApplyPatch { id: String },
|
||||
@@ -343,20 +349,20 @@ fn exec_options() -> Vec<ApprovalOption> {
|
||||
vec![
|
||||
ApprovalOption {
|
||||
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,
|
||||
shortcut: Some('y'),
|
||||
},
|
||||
ApprovalOption {
|
||||
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(),
|
||||
decision: ReviewDecision::ApprovedForSession,
|
||||
shortcut: Some('a'),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Cancel".to_string(),
|
||||
description: "(N) Do not run the command".to_string(),
|
||||
description: "Do not run the command".to_string(),
|
||||
decision: ReviewDecision::Abort,
|
||||
shortcut: Some('n'),
|
||||
},
|
||||
@@ -367,13 +373,13 @@ fn patch_options() -> Vec<ApprovalOption> {
|
||||
vec![
|
||||
ApprovalOption {
|
||||
label: "Approve".to_string(),
|
||||
description: "(Y) Apply the proposed changes".to_string(),
|
||||
description: "Apply the proposed changes".to_string(),
|
||||
decision: ReviewDecision::Approved,
|
||||
shortcut: Some('y'),
|
||||
},
|
||||
ApprovalOption {
|
||||
label: "Cancel".to_string(),
|
||||
description: "(N) Do not apply the changes".to_string(),
|
||||
description: "Do not apply the changes".to_string(),
|
||||
decision: ReviewDecision::Abort,
|
||||
shortcut: Some('n'),
|
||||
},
|
||||
@@ -516,8 +522,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let view = ApprovalOverlay::new(exec_request, tx);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6));
|
||||
view.render(Rect::new(0, 0, 80, 6), &mut buf);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
|
||||
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
|
||||
|
||||
let rendered: Vec<String> = (0..buf.area.height)
|
||||
.map(|row| {
|
||||
@@ -529,7 +535,7 @@ mod tests {
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Command: echo hello world")),
|
||||
.any(|line| line.contains("echo hello world")),
|
||||
"expected header to include command snippet, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use super::CancellationEvent;
|
||||
|
||||
/// 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
|
||||
/// scheduled after this call.
|
||||
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
|
||||
@@ -21,12 +21,6 @@ pub(crate) trait BottomPaneView {
|
||||
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
|
||||
/// needs a redraw.
|
||||
fn handle_paste(&mut self, _pasted: String) -> bool {
|
||||
|
||||
@@ -6,6 +6,8 @@ use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
@@ -205,13 +207,12 @@ impl WidgetRef for CommandPopup {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let rows = self.rows_from_matches(self.filtered());
|
||||
render_rows(
|
||||
area,
|
||||
area.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
"no matches",
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::Widget;
|
||||
use std::cell::RefCell;
|
||||
|
||||
use crate::render::renderable::Renderable;
|
||||
|
||||
use super::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||
|
||||
use super::CancellationEvent;
|
||||
@@ -94,6 +96,36 @@ impl BottomPaneView for CustomPromptView {
|
||||
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 {
|
||||
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||
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 {
|
||||
|
||||
@@ -3,6 +3,9 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::render::Insets;
|
||||
use crate::render::RectExt;
|
||||
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
@@ -139,13 +142,12 @@ impl WidgetRef for &FileSearchPopup {
|
||||
};
|
||||
|
||||
render_rows(
|
||||
area,
|
||||
area.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
buf,
|
||||
&rows_all,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
empty_message,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,23 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use textwrap::wrap;
|
||||
|
||||
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::bottom_pane_view::BottomPaneView;
|
||||
@@ -23,12 +31,6 @@ use super::selection_popup_common::render_rows;
|
||||
/// One selectable item in the generic selection list.
|
||||
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 name: String,
|
||||
pub description: Option<String>,
|
||||
@@ -38,20 +40,31 @@ pub(crate) struct SelectionItem {
|
||||
pub search_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SelectionViewParams {
|
||||
pub title: String,
|
||||
pub title: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
pub footer_hint: Option<String>,
|
||||
pub items: Vec<SelectionItem>,
|
||||
pub is_searchable: bool,
|
||||
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 {
|
||||
title: String,
|
||||
subtitle: Option<String>,
|
||||
footer_hint: Option<String>,
|
||||
items: Vec<SelectionItem>,
|
||||
state: ScrollState,
|
||||
@@ -62,23 +75,22 @@ pub(crate) struct ListSelectionView {
|
||||
search_placeholder: Option<String>,
|
||||
filtered_indices: Vec<usize>,
|
||||
last_selected_actual_idx: Option<usize>,
|
||||
header: Vec<HeaderLine>,
|
||||
header: Box<dyn Renderable>,
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
title: params.title,
|
||||
subtitle: params.subtitle,
|
||||
footer_hint: params.footer_hint,
|
||||
items: params.items,
|
||||
state: ScrollState::new(),
|
||||
@@ -93,7 +105,7 @@ impl ListSelectionView {
|
||||
},
|
||||
filtered_indices: Vec::new(),
|
||||
last_selected_actual_idx: None,
|
||||
header: params.header,
|
||||
header,
|
||||
};
|
||||
s.apply_filter();
|
||||
s
|
||||
@@ -171,7 +183,7 @@ impl ListSelectionView {
|
||||
.filter_map(|(visible_idx, actual_idx)| {
|
||||
self.items.get(*actual_idx).map(|item| {
|
||||
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_with_marker = if item.is_current {
|
||||
format!("{name} (current)")
|
||||
@@ -179,7 +191,13 @@ impl ListSelectionView {
|
||||
item.name.clone()
|
||||
};
|
||||
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 {
|
||||
name: display_name,
|
||||
match_indices: None,
|
||||
@@ -231,39 +249,6 @@ impl ListSelectionView {
|
||||
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
|
||||
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 {
|
||||
@@ -299,6 +284,24 @@ impl BottomPaneView for ListSelectionView {
|
||||
self.search_query.push(c);
|
||||
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 {
|
||||
code: KeyCode::Enter,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
@@ -316,7 +319,9 @@ impl BottomPaneView for ListSelectionView {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for ListSelectionView {
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// 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.
|
||||
@@ -324,19 +329,13 @@ impl BottomPaneView for ListSelectionView {
|
||||
|
||||
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,
|
||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
||||
let mut height = self.header_height(width);
|
||||
height = height.saturating_add(rows_height + 2);
|
||||
let mut height = self.header.desired_height(width);
|
||||
height = height.saturating_add(rows_height + 3);
|
||||
if self.is_searchable {
|
||||
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() {
|
||||
height = height.saturating_add(2);
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
height
|
||||
}
|
||||
@@ -346,52 +345,42 @@ impl BottomPaneView for ListSelectionView {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut next_y = area.y;
|
||||
let header_spans = self.header_spans_for_width(area.width);
|
||||
for spans in header_spans.into_iter() {
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let row = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
|
||||
if spans.is_empty() {
|
||||
prefixed.push(String::new().into());
|
||||
} else {
|
||||
prefixed.extend(spans);
|
||||
}
|
||||
Paragraph::new(Line::from(prefixed)).render(row, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
let [content_area, footer_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
Block::default()
|
||||
.style(user_message_style(terminal_palette::default_bg()))
|
||||
.render(content_area, buf);
|
||||
|
||||
let header_height = self.header.desired_height(content_area.width);
|
||||
let rows = self.build_rows();
|
||||
let rows_height =
|
||||
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, content_area.width);
|
||||
let [header_area, _, search_area, list_area] = Layout::vertical([
|
||||
Constraint::Max(header_height),
|
||||
Constraint::Max(1),
|
||||
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
|
||||
Constraint::Length(rows_height),
|
||||
])
|
||||
.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 {
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
if self.is_searchable {
|
||||
Line::from(self.search_query.clone()).render(search_area, buf);
|
||||
let query_span: Span<'static> = if self.search_query.is_empty() {
|
||||
self.search_placeholder
|
||||
.as_ref()
|
||||
@@ -400,80 +389,40 @@ impl BottomPaneView for ListSelectionView {
|
||||
} else {
|
||||
self.search_query.clone().into()
|
||||
};
|
||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
|
||||
.render(search_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
Line::from(query_span).render(search_area, buf);
|
||||
}
|
||||
|
||||
if let Some(sub) = &self.subtitle {
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let subtitle_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
if list_area.height > 0 {
|
||||
let list_area = Rect {
|
||||
x: list_area.x - 2,
|
||||
y: list_area.y,
|
||||
width: list_area.width + 2,
|
||||
height: list_area.height,
|
||||
};
|
||||
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(
|
||||
rows_area,
|
||||
list_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
list_area.height as usize,
|
||||
"no matches",
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(hint) = &self.footer_hint {
|
||||
let footer_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + area.height - 1,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
let hint_area = Rect {
|
||||
x: footer_area.x + 2,
|
||||
y: footer_area.y,
|
||||
width: footer_area.width.saturating_sub(2),
|
||||
height: footer_area.height,
|
||||
};
|
||||
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
|
||||
Line::from(hint.clone().dim()).render(hint_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::BottomPaneView;
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||
@@ -504,7 +453,7 @@ mod tests {
|
||||
];
|
||||
ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: "Select Approval Mode".to_string(),
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
subtitle: subtitle.map(str::to_string),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
@@ -516,7 +465,7 @@ mod tests {
|
||||
|
||||
fn render_lines(view: &ListSelectionView) -> String {
|
||||
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 mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
@@ -567,7 +516,7 @@ mod tests {
|
||||
}];
|
||||
let mut view = ListSelectionView::new(
|
||||
SelectionViewParams {
|
||||
title: "Select Approval Mode".to_string(),
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
@@ -579,6 +528,9 @@ mod tests {
|
||||
view.set_search_query("filters".to_string());
|
||||
|
||||
let lines = render_lines(&view);
|
||||
assert!(lines.contains("▌ filters"));
|
||||
assert!(
|
||||
lines.contains("filters"),
|
||||
"expected search query line to include rendered query, got {lines:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,13 @@ use ratatui::layout::Rect;
|
||||
// Note: Table-based layout previously used Constraint; the manual renderer
|
||||
// below no longer requires it.
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use super::scroll_state::ScrollState;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
|
||||
/// A generic representation of a display row for selection popups.
|
||||
pub(crate) struct GenericDisplayRow {
|
||||
@@ -23,8 +19,6 @@ pub(crate) struct GenericDisplayRow {
|
||||
pub description: Option<String>, // optional grey text after the name
|
||||
}
|
||||
|
||||
impl GenericDisplayRow {}
|
||||
|
||||
/// 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
|
||||
/// description.
|
||||
@@ -117,71 +111,19 @@ pub(crate) fn render_rows(
|
||||
state: &ScrollState,
|
||||
max_results: usize,
|
||||
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 content_area.height > 0 {
|
||||
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
|
||||
para.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: content_area.y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
if area.height > 0 {
|
||||
Line::from(empty_message.dim().italic()).render(area, buf);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which logical rows (items) are visible given the selection and
|
||||
// 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
|
||||
.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));
|
||||
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
|
||||
// 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
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(start_idx)
|
||||
.take(visible_items)
|
||||
{
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
if cur_y >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -217,7 +159,7 @@ pub(crate) fn render_rows(
|
||||
description,
|
||||
} = row;
|
||||
|
||||
let full_line = build_full_line(
|
||||
let mut full_line = build_full_line(
|
||||
&GenericDisplayRow {
|
||||
name: name.clone(),
|
||||
match_indices: match_indices.clone(),
|
||||
@@ -226,32 +168,31 @@ pub(crate) fn render_rows(
|
||||
},
|
||||
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.
|
||||
use crate::wrapping::RtOptions;
|
||||
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(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
let wrapped = word_wrap_line(&full_line, options);
|
||||
|
||||
// Render the wrapped lines.
|
||||
for mut line in wrapped {
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
for line in wrapped {
|
||||
if cur_y >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
if Some(i) == state.selected_idx {
|
||||
// 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(
|
||||
line.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
x: area.x,
|
||||
y: cur_y,
|
||||
width: content_area.width,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
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
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
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
|
||||
|
||||
@@ -79,7 +79,6 @@ use crate::history_cell;
|
||||
use crate::history_cell::AgentMessageCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::McpToolCallCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::status::RateLimitSnapshotDisplay;
|
||||
@@ -534,9 +533,6 @@ impl ChatWidget {
|
||||
|
||||
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
|
||||
self.add_to_history(history_cell::new_patch_event(
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: event.auto_approved,
|
||||
},
|
||||
event.changes,
|
||||
&self.config.cwd,
|
||||
));
|
||||
@@ -736,8 +732,6 @@ impl ChatWidget {
|
||||
|
||||
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
||||
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))
|
||||
.unwrap_or_else(|_| ev.command.join(" "));
|
||||
self.notify(Notification::ExecApprovalRequested { command });
|
||||
@@ -757,16 +751,12 @@ impl ChatWidget {
|
||||
ev: ApplyPatchApprovalRequestEvent,
|
||||
) {
|
||||
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 {
|
||||
id,
|
||||
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.request_redraw();
|
||||
@@ -1631,7 +1621,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
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(
|
||||
"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 {
|
||||
title: "Select Approval Mode".to_string(),
|
||||
title: Some("Select Approval Mode".to_string()),
|
||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||
items,
|
||||
..Default::default()
|
||||
@@ -1852,7 +1842,7 @@ impl ChatWidget {
|
||||
});
|
||||
|
||||
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()),
|
||||
items,
|
||||
..Default::default()
|
||||
@@ -1888,7 +1878,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
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()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
@@ -1929,7 +1919,7 @@ impl ChatWidget {
|
||||
}
|
||||
|
||||
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()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
@@ -2154,7 +2144,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
||||
}
|
||||
|
||||
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()),
|
||||
items,
|
||||
is_searchable: true,
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&approved_lines)
|
||||
---
|
||||
• Change Approved foo.txt (+1 -0)
|
||||
• Added foo.txt (+1 -0)
|
||||
1 +hello
|
||||
|
||||
@@ -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
|
||||
@@ -1,18 +1,16 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: terminal.backend()
|
||||
expression: terminal.backend().vt100().screen().contents()
|
||||
---
|
||||
" "
|
||||
"▌ 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
|
||||
|
||||
@@ -3,14 +3,15 @@ source: tui/src/chatwidget/tests.rs
|
||||
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 "
|
||||
" "
|
||||
|
||||
@@ -3,14 +3,17 @@ source: tui/src/chatwidget/tests.rs
|
||||
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 "
|
||||
" "
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&proposed_multi)
|
||||
---
|
||||
• Proposed Command
|
||||
└ echo line1
|
||||
echo line2
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&proposed)
|
||||
---
|
||||
• Proposed Command
|
||||
└ echo hello world
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -3,16 +3,17 @@ source: tui/src/chatwidget/tests.rs
|
||||
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 "
|
||||
" "
|
||||
|
||||
@@ -83,66 +83,6 @@ fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json
|
||||
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]
|
||||
fn resumed_initial_messages_render_history() {
|
||||
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),
|
||||
});
|
||||
|
||||
// Snapshot the Proposed Command cell emitted into history
|
||||
let proposed = drain_insert_history(&mut rx)
|
||||
.pop()
|
||||
.expect("expected proposed command cell");
|
||||
assert_snapshot!(
|
||||
"exec_approval_history_proposed_short",
|
||||
lines_to_single_string(&proposed)
|
||||
let proposed_cells = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
proposed_cells.is_empty(),
|
||||
"expected approval request to render via modal without emitting history cells"
|
||||
);
|
||||
|
||||
// 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
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
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() {
|
||||
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 {
|
||||
call_id: "call-multi".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(),
|
||||
msg: EventMsg::ExecApprovalRequest(ev_multi),
|
||||
});
|
||||
let proposed_multi = drain_insert_history(&mut rx)
|
||||
.pop()
|
||||
.expect("expected proposed multiline command cell");
|
||||
assert_snapshot!(
|
||||
"exec_approval_history_proposed_multiline",
|
||||
lines_to_single_string(&proposed_multi)
|
||||
let proposed_multi = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
proposed_multi.is_empty(),
|
||||
"expected multiline approval request to render 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_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 " ..."
|
||||
@@ -519,7 +479,11 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||
id: "sub-long".into(),
|
||||
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));
|
||||
let aborted_long = drain_insert_history(&mut rx)
|
||||
.pop()
|
||||
@@ -935,18 +899,21 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(chat).render_ref(area, &mut buf);
|
||||
let mut row = String::new();
|
||||
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
|
||||
let y = 1u16.min(height.saturating_sub(1));
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
row.push(' ');
|
||||
} else {
|
||||
row.push_str(s);
|
||||
for y in 0..area.height {
|
||||
let mut row = String::new();
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
row.push(' ');
|
||||
} else {
|
||||
row.push_str(s);
|
||||
}
|
||||
}
|
||||
if !row.trim().is_empty() {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
row
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1181,12 +1148,19 @@ fn approval_modal_exec_snapshot() {
|
||||
// Render to a fixed-size test terminal and snapshot.
|
||||
// Call desired_height first and use that exact height for rendering.
|
||||
let height = chat.desired_height(80);
|
||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
|
||||
.expect("create terminal");
|
||||
let mut 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
|
||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||
.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
|
||||
@@ -1470,13 +1444,27 @@ fn apply_patch_events_emit_history_cells() {
|
||||
msg: EventMsg::ApplyPatchApprovalRequest(ev),
|
||||
});
|
||||
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!(
|
||||
blob.contains("Proposed Change"),
|
||||
"missing proposed change header: {blob:?}"
|
||||
cells.is_empty(),
|
||||
"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)
|
||||
let mut changes2 = HashMap::new();
|
||||
changes2.insert(
|
||||
@@ -1562,8 +1550,8 @@ fn apply_patch_manual_approval_adjusts_header() {
|
||||
assert!(!cells.is_empty(), "expected apply block cell to be sent");
|
||||
let blob = lines_to_single_string(cells.last().unwrap());
|
||||
assert!(
|
||||
blob.contains("Change Approved foo.txt"),
|
||||
"expected change approved summary: {blob:?}"
|
||||
blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"),
|
||||
"expected apply summary header for foo.txt: {blob:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1587,9 +1575,11 @@ fn apply_patch_manual_flow_snapshot() {
|
||||
grant_root: None,
|
||||
}),
|
||||
});
|
||||
let proposed_lines = drain_insert_history(&mut rx)
|
||||
.pop()
|
||||
.expect("proposed patch cell");
|
||||
let history_before_apply = drain_insert_history(&mut rx);
|
||||
assert!(
|
||||
history_before_apply.is_empty(),
|
||||
"expected approval modal to defer history emission"
|
||||
);
|
||||
|
||||
let mut apply_changes = HashMap::new();
|
||||
apply_changes.insert(
|
||||
@@ -1610,10 +1600,6 @@ fn apply_patch_manual_flow_snapshot() {
|
||||
.pop()
|
||||
.expect("approved patch cell");
|
||||
|
||||
assert_snapshot!(
|
||||
"apply_patch_manual_flow_history_proposed",
|
||||
lines_to_single_string(&proposed_lines)
|
||||
);
|
||||
assert_snapshot!(
|
||||
"apply_patch_manual_flow_history_approved",
|
||||
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);
|
||||
assert!(
|
||||
!cells.is_empty(),
|
||||
"expected a history cell with the proposed patch summary"
|
||||
);
|
||||
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:?}"
|
||||
cells.is_empty(),
|
||||
"expected approval request to render via modal instead of history"
|
||||
);
|
||||
|
||||
// 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!(
|
||||
blob.contains("README.md"),
|
||||
"missing per-file diff summary: {blob:?}"
|
||||
saw_line1 && saw_line2,
|
||||
"expected modal to show per-line diff summary"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
use diffy::Hunk;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line as RtLine;
|
||||
use ratatui::text::Span as RtSpan;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
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::protocol::FileChange;
|
||||
|
||||
@@ -23,24 +27,57 @@ enum DiffLineType {
|
||||
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(
|
||||
changes: &HashMap<PathBuf, FileChange>,
|
||||
event_type: PatchEventType,
|
||||
cwd: &Path,
|
||||
wrap_cols: usize,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
let rows = collect_rows(changes);
|
||||
let header_kind = match event_type {
|
||||
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)
|
||||
render_changes_block(rows, wrap_cols, cwd)
|
||||
}
|
||||
|
||||
// Shared row for per-file presentation
|
||||
@@ -81,30 +118,18 @@ fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
|
||||
rows
|
||||
}
|
||||
|
||||
enum HeaderKind {
|
||||
ProposedChange,
|
||||
Edited,
|
||||
ChangeApproved,
|
||||
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
|
||||
}
|
||||
|
||||
fn render_changes_block(
|
||||
rows: Vec<Row>,
|
||||
wrap_cols: usize,
|
||||
header_kind: HeaderKind,
|
||||
cwd: &Path,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtLine<'static>> {
|
||||
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 mut spans = Vec::new();
|
||||
@@ -121,66 +146,31 @@ fn render_changes_block(
|
||||
let file_count = rows.len();
|
||||
let noun = if file_count == 1 { "file" } else { "files" };
|
||||
let mut header_spans: Vec<RtSpan<'static>> = vec!["• ".into()];
|
||||
match header_kind {
|
||||
HeaderKind::ProposedChange => {
|
||||
header_spans.push("Proposed Change".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!(" to {file_count} {noun} ").into());
|
||||
header_spans.extend(render_line_count_summary(total_added, total_removed));
|
||||
}
|
||||
}
|
||||
HeaderKind::Edited => {
|
||||
if let [row] = &rows[..] {
|
||||
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));
|
||||
}
|
||||
}
|
||||
if let [row] = &rows[..] {
|
||||
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));
|
||||
}
|
||||
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() {
|
||||
// Insert a blank separator between file chunks (except before the first)
|
||||
if idx > 0 {
|
||||
out.push("".into());
|
||||
}
|
||||
// File header line (skip when single-file header already shows the name)
|
||||
let skip_file_header =
|
||||
matches!(header_kind, HeaderKind::ProposedChange | HeaderKind::Edited)
|
||||
&& file_count == 1;
|
||||
let skip_file_header = file_count == 1;
|
||||
if !skip_file_header {
|
||||
let mut header: Vec<RtSpan<'static>> = Vec::new();
|
||||
header.push(" └ ".dim());
|
||||
@@ -190,71 +180,77 @@ fn render_changes_block(
|
||||
out.push(RtLine::from(header));
|
||||
}
|
||||
|
||||
match r.change {
|
||||
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;
|
||||
render_change(&r.change, &mut out, wrap_cols);
|
||||
}
|
||||
|
||||
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,
|
||||
term_cols,
|
||||
));
|
||||
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,
|
||||
term_cols,
|
||||
));
|
||||
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,
|
||||
term_cols,
|
||||
));
|
||||
old_ln += 1;
|
||||
new_ln += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usize) {
|
||||
match change {
|
||||
FileChange::Add { content } => {
|
||||
for (i, raw) in content.lines().enumerate() {
|
||||
out.extend(push_wrapped_diff_line(
|
||||
i + 1,
|
||||
DiffLineType::Insert,
|
||||
raw,
|
||||
width,
|
||||
));
|
||||
}
|
||||
}
|
||||
FileChange::Delete { content } => {
|
||||
for (i, raw) in content.lines().enumerate() {
|
||||
out.extend(push_wrapped_diff_line(
|
||||
i + 1,
|
||||
DiffLineType::Delete,
|
||||
raw,
|
||||
width,
|
||||
));
|
||||
}
|
||||
}
|
||||
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();
|
||||
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 {
|
||||
@@ -300,7 +294,7 @@ fn push_wrapped_diff_line(
|
||||
line_number: usize,
|
||||
kind: DiffLineType,
|
||||
text: &str,
|
||||
term_cols: usize,
|
||||
width: usize,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
let indent = " ";
|
||||
let ln_str = line_number.to_string();
|
||||
@@ -325,7 +319,7 @@ fn push_wrapped_diff_line(
|
||||
// Fit the content for the current terminal row:
|
||||
// compute how many columns are available after the prefix, then split
|
||||
// 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
|
||||
.char_indices()
|
||||
.nth(available_content_cols)
|
||||
@@ -385,11 +379,8 @@ mod tests {
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
fn diff_summary_for_tests(
|
||||
changes: &HashMap<PathBuf, FileChange>,
|
||||
event_type: PatchEventType,
|
||||
) -> Vec<RtLine<'static>> {
|
||||
create_diff_summary(changes, event_type, &PathBuf::from("/"), 80)
|
||||
fn diff_summary_for_tests(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
|
||||
create_diff_summary(changes, &PathBuf::from("/"), 80)
|
||||
}
|
||||
|
||||
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
|
||||
@@ -421,42 +412,6 @@ mod tests {
|
||||
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]
|
||||
fn ui_snapshot_wrap_behavior_insert() {
|
||||
// Narrow width to force wrapping within our diff line rendering
|
||||
@@ -469,71 +424,6 @@ mod tests {
|
||||
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]
|
||||
fn ui_snapshot_apply_update_block() {
|
||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||
@@ -549,12 +439,8 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
for (name, auto_approved) in [
|
||||
("apply_update_block", true),
|
||||
("apply_update_block_manual", false),
|
||||
] {
|
||||
let lines =
|
||||
diff_summary_for_tests(&changes, PatchEventType::ApplyBegin { auto_approved });
|
||||
for name in ["apply_update_block", "apply_update_block_manual"] {
|
||||
let lines = diff_summary_for_tests(&changes);
|
||||
|
||||
snapshot_lines(name, lines, 80, 12);
|
||||
}
|
||||
@@ -575,12 +461,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let lines = diff_summary_for_tests(
|
||||
&changes,
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
},
|
||||
);
|
||||
let lines = diff_summary_for_tests(&changes);
|
||||
|
||||
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
|
||||
}
|
||||
@@ -608,12 +489,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let lines = diff_summary_for_tests(
|
||||
&changes,
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
},
|
||||
);
|
||||
let lines = diff_summary_for_tests(&changes);
|
||||
|
||||
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
|
||||
}
|
||||
@@ -628,12 +504,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let lines = diff_summary_for_tests(
|
||||
&changes,
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
},
|
||||
);
|
||||
let lines = diff_summary_for_tests(&changes);
|
||||
|
||||
snapshot_lines("apply_add_block", lines, 80, 10);
|
||||
}
|
||||
@@ -652,12 +523,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let lines = diff_summary_for_tests(
|
||||
&changes,
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
},
|
||||
);
|
||||
let lines = diff_summary_for_tests(&changes);
|
||||
|
||||
// Cleanup best-effort; rendering has already read the file
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
@@ -681,14 +547,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let lines = create_diff_summary(
|
||||
&changes,
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
},
|
||||
&PathBuf::from("/"),
|
||||
72,
|
||||
);
|
||||
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72);
|
||||
|
||||
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
|
||||
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
|
||||
@@ -711,14 +570,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let mut lines = create_diff_summary(
|
||||
&changes,
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
},
|
||||
&PathBuf::from("/"),
|
||||
28,
|
||||
);
|
||||
let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
|
||||
// Drop the combined header for this text-only snapshot
|
||||
if !lines.is_empty() {
|
||||
lines.remove(0);
|
||||
@@ -745,14 +597,7 @@ mod tests {
|
||||
},
|
||||
);
|
||||
|
||||
let lines = create_diff_summary(
|
||||
&changes,
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: true,
|
||||
},
|
||||
&cwd,
|
||||
80,
|
||||
);
|
||||
let lines = create_diff_summary(&changes, &cwd, 80);
|
||||
|
||||
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::exec_cell::TOOL_CALL_MAX_LINES;
|
||||
use crate::exec_cell::output_lines;
|
||||
use crate::exec_cell::spinner;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::markdown::MarkdownCitationContext;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
@@ -50,12 +49,6 @@ use std::time::Instant;
|
||||
use tracing::error;
|
||||
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
|
||||
/// `Vec<Line<'static>>` representation to make it easier to display in a
|
||||
/// scrollable list.
|
||||
@@ -277,19 +270,13 @@ pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PatchHistoryCell {
|
||||
event_type: PatchEventType,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl HistoryCell for PatchHistoryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
create_diff_summary(
|
||||
&self.changes,
|
||||
self.event_type.clone(),
|
||||
&self.cwd,
|
||||
width as usize,
|
||||
)
|
||||
create_diff_summary(&self.changes, &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 path/to/file.rs").
|
||||
pub(crate) fn new_patch_event(
|
||||
event_type: PatchEventType,
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: &Path,
|
||||
) -> PatchHistoryCell {
|
||||
PatchHistoryCell {
|
||||
event_type,
|
||||
changes,
|
||||
cwd: cwd.to_path_buf(),
|
||||
}
|
||||
@@ -1052,27 +1037,6 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
|
||||
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(
|
||||
full_reasoning_buffer: String,
|
||||
config: &Config,
|
||||
|
||||
@@ -3,13 +3,14 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::buffer::Cell;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
@@ -22,6 +23,7 @@ use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
pub(crate) enum Overlay {
|
||||
Transcript(TranscriptOverlay),
|
||||
@@ -33,10 +35,17 @@ impl Overlay {
|
||||
Self::Transcript(TranscriptOverlay::new(cells))
|
||||
}
|
||||
|
||||
pub(crate) fn new_static_with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||
pub(crate) fn new_static_with_lines(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||
Self::Static(StaticOverlay::with_title(lines, title))
|
||||
}
|
||||
|
||||
pub(crate) fn new_static_with_renderables(
|
||||
renderables: Vec<Box<dyn Renderable>>,
|
||||
title: String,
|
||||
) -> Self {
|
||||
Self::Static(StaticOverlay::with_renderables(renderables, title))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match self {
|
||||
Overlay::Transcript(o) => o.handle_event(tui, event),
|
||||
@@ -78,57 +87,53 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
|
||||
|
||||
/// Generic widget for rendering a pager view.
|
||||
struct PagerView {
|
||||
texts: Vec<Text<'static>>,
|
||||
renderables: Vec<Box<dyn Renderable>>,
|
||||
scroll_offset: usize,
|
||||
title: String,
|
||||
wrap_cache: Option<WrapCache>,
|
||||
last_content_height: Option<usize>,
|
||||
last_rendered_height: Option<usize>,
|
||||
/// If set, on next render ensure this chunk is visible.
|
||||
pending_scroll_chunk: Option<usize>,
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
fn new(texts: Vec<Text<'static>>, title: String, scroll_offset: usize) -> Self {
|
||||
fn new(renderables: Vec<Box<dyn Renderable>>, title: String, scroll_offset: usize) -> Self {
|
||||
Self {
|
||||
texts,
|
||||
renderables,
|
||||
scroll_offset,
|
||||
title,
|
||||
wrap_cache: None,
|
||||
last_content_height: None,
|
||||
last_rendered_height: None,
|
||||
pending_scroll_chunk: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn content_height(&self, width: u16) -> usize {
|
||||
self.renderables
|
||||
.iter()
|
||||
.map(|c| c.desired_height(width) as usize)
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
self.render_header(area, buf);
|
||||
let content_area = self.scroll_area(area);
|
||||
let content_area = self.content_area(area);
|
||||
self.update_last_content_height(content_area.height);
|
||||
self.ensure_wrapped(content_area.width);
|
||||
let content_height = self.content_height(content_area.width);
|
||||
self.last_rendered_height = Some(content_height);
|
||||
// If there is a pending request to scroll a specific chunk into view,
|
||||
// satisfy it now that wrapping is up to date for this width.
|
||||
if let (Some(idx), Some(cache)) =
|
||||
(self.pending_scroll_chunk.take(), self.wrap_cache.as_ref())
|
||||
&& let Some(range) = cache.chunk_ranges.get(idx).cloned()
|
||||
{
|
||||
self.ensure_range_visible(range, content_area.height as usize, cache.wrapped.len());
|
||||
if let Some(idx) = self.pending_scroll_chunk.take() {
|
||||
self.ensure_chunk_visible(idx, content_area);
|
||||
}
|
||||
// Compute page bounds without holding an immutable borrow on cache while mutating self
|
||||
let wrapped_len = self
|
||||
.wrap_cache
|
||||
.as_ref()
|
||||
.map(|c| c.wrapped.len())
|
||||
.unwrap_or(0);
|
||||
self.scroll_offset = self
|
||||
.scroll_offset
|
||||
.min(wrapped_len.saturating_sub(content_area.height as usize));
|
||||
let start = self.scroll_offset;
|
||||
let end = (start + content_area.height as usize).min(wrapped_len);
|
||||
.min(content_height.saturating_sub(content_area.height as usize));
|
||||
|
||||
let wrapped = self.cached();
|
||||
let page = &wrapped[start..end];
|
||||
self.render_content_page_prepared(content_area, buf, page);
|
||||
self.render_bottom_bar(area, content_area, buf, wrapped);
|
||||
self.render_content(content_area, buf);
|
||||
|
||||
self.render_bottom_bar(area, content_area, buf, content_height);
|
||||
}
|
||||
|
||||
fn render_header(&self, area: Rect, buf: &mut Buffer) {
|
||||
@@ -139,20 +144,38 @@ impl PagerView {
|
||||
header.dim().render_ref(area, buf);
|
||||
}
|
||||
|
||||
// Removed unused render_content_page (replaced by render_content_page_prepared)
|
||||
fn render_content(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut y = -(self.scroll_offset as isize);
|
||||
let mut drawn_bottom = area.y;
|
||||
for renderable in &self.renderables {
|
||||
let top = y;
|
||||
let height = renderable.desired_height(area.width) as isize;
|
||||
y += height;
|
||||
let bottom = y;
|
||||
if bottom < area.y as isize {
|
||||
continue;
|
||||
}
|
||||
if top > area.y as isize + area.height as isize {
|
||||
break;
|
||||
}
|
||||
if top < 0 {
|
||||
let drawn = render_offset_content(area, buf, &**renderable, (-top) as u16);
|
||||
drawn_bottom = drawn_bottom.max(area.y + drawn);
|
||||
} else {
|
||||
let draw_height = (height as u16).min(area.height.saturating_sub(top as u16));
|
||||
let draw_area = Rect::new(area.x, area.y + top as u16, area.width, draw_height);
|
||||
renderable.render(draw_area, buf);
|
||||
drawn_bottom = drawn_bottom.max(draw_area.y.saturating_add(draw_area.height));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content_page_prepared(&self, area: Rect, buf: &mut Buffer, page: &[Line<'static>]) {
|
||||
Clear.render(area, buf);
|
||||
Paragraph::new(page.to_vec()).render_ref(area, buf);
|
||||
|
||||
let visible = page.len();
|
||||
if visible < area.height as usize {
|
||||
for i in 0..(area.height as usize - visible) {
|
||||
let add = ((visible + i).min(u16::MAX as usize)) as u16;
|
||||
let y = area.y.saturating_add(add);
|
||||
Span::from("~")
|
||||
.dim()
|
||||
.render_ref(Rect::new(area.x, y, 1, 1), buf);
|
||||
for y in drawn_bottom..area.bottom() {
|
||||
if area.width == 0 {
|
||||
break;
|
||||
}
|
||||
buf[(area.x, y)] = Cell::from('~');
|
||||
for x in area.x + 1..area.right() {
|
||||
buf[(x, y)] = Cell::from(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +185,7 @@ impl PagerView {
|
||||
full_area: Rect,
|
||||
content_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
wrapped: &[Line<'static>],
|
||||
total_len: usize,
|
||||
) {
|
||||
let sep_y = content_area.bottom();
|
||||
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
|
||||
@@ -170,10 +193,10 @@ impl PagerView {
|
||||
Span::from("─".repeat(sep_rect.width as usize))
|
||||
.dim()
|
||||
.render_ref(sep_rect, buf);
|
||||
let percent = if wrapped.is_empty() {
|
||||
let percent = if total_len == 0 {
|
||||
100
|
||||
} else {
|
||||
let max_scroll = wrapped.len().saturating_sub(content_area.height as usize);
|
||||
let max_scroll = total_len.saturating_sub(content_area.height as usize);
|
||||
if max_scroll == 0 {
|
||||
100
|
||||
} else {
|
||||
@@ -210,7 +233,7 @@ impl PagerView {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
let area = self.scroll_area(tui.terminal.viewport_area);
|
||||
let area = self.content_area(tui.terminal.viewport_area);
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
|
||||
}
|
||||
KeyEvent {
|
||||
@@ -218,7 +241,7 @@ impl PagerView {
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
let area = self.scroll_area(tui.terminal.viewport_area);
|
||||
let area = self.content_area(tui.terminal.viewport_area);
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
|
||||
}
|
||||
KeyEvent {
|
||||
@@ -248,7 +271,7 @@ impl PagerView {
|
||||
self.last_content_height = Some(height as usize);
|
||||
}
|
||||
|
||||
fn scroll_area(&self, area: Rect) -> Rect {
|
||||
fn content_area(&self, area: Rect) -> Rect {
|
||||
let mut area = area;
|
||||
area.y = area.y.saturating_add(1);
|
||||
area.height = area.height.saturating_sub(2);
|
||||
@@ -256,67 +279,24 @@ impl PagerView {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WrapCache {
|
||||
width: u16,
|
||||
wrapped: Vec<Line<'static>>,
|
||||
/// For each input Text chunk, the inclusive-excluded range of wrapped lines produced.
|
||||
chunk_ranges: Vec<std::ops::Range<usize>>,
|
||||
base_len: usize,
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
fn ensure_wrapped(&mut self, width: u16) {
|
||||
let width = width.max(1);
|
||||
let needs = match self.wrap_cache {
|
||||
Some(ref c) => c.width != width || c.base_len != self.texts.len(),
|
||||
None => true,
|
||||
};
|
||||
if !needs {
|
||||
return;
|
||||
}
|
||||
let mut wrapped: Vec<Line<'static>> = Vec::new();
|
||||
let mut chunk_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(self.texts.len());
|
||||
for text in &self.texts {
|
||||
let start = wrapped.len();
|
||||
for line in &text.lines {
|
||||
let ws = crate::wrapping::word_wrap_line(line, width as usize);
|
||||
push_owned_lines(&ws, &mut wrapped);
|
||||
}
|
||||
let end = wrapped.len();
|
||||
chunk_ranges.push(start..end);
|
||||
}
|
||||
self.wrap_cache = Some(WrapCache {
|
||||
width,
|
||||
wrapped,
|
||||
chunk_ranges,
|
||||
base_len: self.texts.len(),
|
||||
});
|
||||
}
|
||||
|
||||
fn cached(&self) -> &[Line<'static>] {
|
||||
if let Some(cache) = self.wrap_cache.as_ref() {
|
||||
&cache.wrapped
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
fn is_scrolled_to_bottom(&self) -> bool {
|
||||
if self.scroll_offset == usize::MAX {
|
||||
return true;
|
||||
}
|
||||
let Some(cache) = &self.wrap_cache else {
|
||||
return false;
|
||||
};
|
||||
let Some(height) = self.last_content_height else {
|
||||
return false;
|
||||
};
|
||||
if cache.wrapped.is_empty() {
|
||||
if self.renderables.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let visible = height.min(cache.wrapped.len());
|
||||
let max_scroll = cache.wrapped.len().saturating_sub(visible);
|
||||
let Some(total_height) = self.last_rendered_height else {
|
||||
return false;
|
||||
};
|
||||
if total_height <= height {
|
||||
return true;
|
||||
}
|
||||
let max_scroll = total_height.saturating_sub(height);
|
||||
self.scroll_offset >= max_scroll
|
||||
}
|
||||
|
||||
@@ -325,32 +305,57 @@ impl PagerView {
|
||||
self.pending_scroll_chunk = Some(chunk_index);
|
||||
}
|
||||
|
||||
fn ensure_range_visible(
|
||||
&mut self,
|
||||
range: std::ops::Range<usize>,
|
||||
viewport_height: usize,
|
||||
total_wrapped: usize,
|
||||
) {
|
||||
if viewport_height == 0 || total_wrapped == 0 {
|
||||
fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
|
||||
if area.height == 0 || idx >= self.renderables.len() {
|
||||
return;
|
||||
}
|
||||
let first = range.start.min(total_wrapped.saturating_sub(1));
|
||||
let last = range
|
||||
.end
|
||||
.saturating_sub(1)
|
||||
.min(total_wrapped.saturating_sub(1));
|
||||
let current_top = self.scroll_offset.min(total_wrapped.saturating_sub(1));
|
||||
let current_bottom = current_top.saturating_add(viewport_height.saturating_sub(1));
|
||||
|
||||
let first = self
|
||||
.renderables
|
||||
.iter()
|
||||
.take(idx)
|
||||
.map(|r| r.desired_height(area.width) as usize)
|
||||
.sum();
|
||||
let last = first + self.renderables[idx].desired_height(area.width) as usize;
|
||||
let current_top = self.scroll_offset;
|
||||
let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize);
|
||||
if first < current_top {
|
||||
self.scroll_offset = first;
|
||||
} else if last > current_bottom {
|
||||
// Scroll just enough so that 'last' is visible at the bottom
|
||||
self.scroll_offset = last.saturating_sub(viewport_height.saturating_sub(1));
|
||||
self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CachedParagraph {
|
||||
paragraph: Paragraph<'static>,
|
||||
height: std::cell::Cell<Option<u16>>,
|
||||
last_width: std::cell::Cell<Option<u16>>,
|
||||
}
|
||||
|
||||
impl CachedParagraph {
|
||||
fn new(paragraph: Paragraph<'static>) -> Self {
|
||||
Self {
|
||||
paragraph,
|
||||
height: std::cell::Cell::new(None),
|
||||
last_width: std::cell::Cell::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for CachedParagraph {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
self.paragraph.render_ref(area, buf);
|
||||
}
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if self.last_width.get() != Some(width) {
|
||||
let height = self.paragraph.line_count(width) as u16;
|
||||
self.height.set(Some(height));
|
||||
self.last_width.set(Some(width));
|
||||
}
|
||||
self.height.get().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TranscriptOverlay {
|
||||
view: PagerView,
|
||||
cells: Vec<Arc<dyn HistoryCell>>,
|
||||
@@ -375,8 +380,8 @@ impl TranscriptOverlay {
|
||||
fn render_cells_to_texts(
|
||||
cells: &[Arc<dyn HistoryCell>],
|
||||
highlight_cell: Option<usize>,
|
||||
) -> Vec<Text<'static>> {
|
||||
let mut texts: Vec<Text<'static>> = Vec::new();
|
||||
) -> Vec<Box<dyn Renderable>> {
|
||||
let mut texts: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
let mut first = true;
|
||||
for (idx, cell) in cells.iter().enumerate() {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
@@ -392,7 +397,9 @@ impl TranscriptOverlay {
|
||||
cell.transcript_lines()
|
||||
};
|
||||
lines.extend(cell_lines);
|
||||
texts.push(Text::from(lines));
|
||||
texts.push(Box::new(CachedParagraph::new(
|
||||
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
|
||||
)));
|
||||
first = false;
|
||||
}
|
||||
texts
|
||||
@@ -406,9 +413,10 @@ impl TranscriptOverlay {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
lines.extend(cell.transcript_lines());
|
||||
self.view.texts.push(Text::from(lines));
|
||||
self.view.renderables.push(Box::new(CachedParagraph::new(
|
||||
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
|
||||
)));
|
||||
self.cells.push(cell);
|
||||
self.view.wrap_cache = None;
|
||||
if follow_bottom {
|
||||
self.view.scroll_offset = usize::MAX;
|
||||
}
|
||||
@@ -416,8 +424,7 @@ impl TranscriptOverlay {
|
||||
|
||||
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
|
||||
self.highlight_cell = cell;
|
||||
self.view.wrap_cache = None;
|
||||
self.view.texts = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
|
||||
self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
|
||||
if let Some(idx) = self.highlight_cell {
|
||||
self.view.scroll_chunk_into_view(idx);
|
||||
}
|
||||
@@ -490,8 +497,17 @@ pub(crate) struct StaticOverlay {
|
||||
|
||||
impl StaticOverlay {
|
||||
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||
Self::with_renderables(
|
||||
vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from(
|
||||
lines,
|
||||
))))],
|
||||
title,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn with_renderables(renderables: Vec<Box<dyn Renderable>>, title: String) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(vec![Text::from(lines)], title, 0),
|
||||
view: PagerView::new(renderables, title, 0),
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
@@ -547,6 +563,33 @@ impl StaticOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_offset_content(
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
renderable: &dyn Renderable,
|
||||
scroll_offset: u16,
|
||||
) -> u16 {
|
||||
let height = renderable.desired_height(area.width);
|
||||
let mut tall_buf = Buffer::empty(Rect::new(
|
||||
0,
|
||||
0,
|
||||
area.width,
|
||||
height.min(area.height + scroll_offset),
|
||||
));
|
||||
renderable.render(*tall_buf.area(), &mut tall_buf);
|
||||
let copy_height = area
|
||||
.height
|
||||
.min(tall_buf.area().height.saturating_sub(scroll_offset));
|
||||
for y in 0..copy_height {
|
||||
let src_y = y + scroll_offset;
|
||||
for x in 0..area.width {
|
||||
buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone();
|
||||
}
|
||||
}
|
||||
|
||||
copy_height
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -558,12 +601,12 @@ mod tests {
|
||||
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::history_cell::new_patch_event;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::text::Text;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestCell {
|
||||
@@ -580,6 +623,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn paragraph_block(label: &str, lines: usize) -> Box<dyn Renderable> {
|
||||
let text = Text::from(
|
||||
(0..lines)
|
||||
.map(|i| Line::from(format!("{label}{i}")))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
Box::new(Paragraph::new(text)) as Box<dyn Renderable>
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_prev_hint_is_visible() {
|
||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||
@@ -657,11 +709,7 @@ mod tests {
|
||||
content: "hello\nworld\n".to_string(),
|
||||
},
|
||||
);
|
||||
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(
|
||||
PatchEventType::ApprovalRequest,
|
||||
approval_changes,
|
||||
&cwd,
|
||||
));
|
||||
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(approval_changes, &cwd));
|
||||
cells.push(approval_cell);
|
||||
|
||||
let mut apply_changes = HashMap::new();
|
||||
@@ -671,13 +719,7 @@ mod tests {
|
||||
content: "hello\nworld\n".to_string(),
|
||||
},
|
||||
);
|
||||
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: false,
|
||||
},
|
||||
apply_changes,
|
||||
&cwd,
|
||||
));
|
||||
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(apply_changes, &cwd));
|
||||
cells.push(apply_begin_cell);
|
||||
|
||||
let apply_end_cell: Arc<dyn HistoryCell> =
|
||||
@@ -711,7 +753,6 @@ mod tests {
|
||||
|
||||
overlay.render(area, &mut buf);
|
||||
overlay.view.scroll_offset = 0;
|
||||
overlay.view.wrap_cache = None;
|
||||
overlay.render(area, &mut buf);
|
||||
|
||||
let snapshot = buffer_to_text(&buf, area);
|
||||
@@ -783,54 +824,89 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() {
|
||||
let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output.";
|
||||
let mut pv = PagerView::new(
|
||||
vec![Text::from(vec![long.into()]), Text::from(vec![long.into()])],
|
||||
fn pager_view_content_height_counts_renderables() {
|
||||
let pv = PagerView::new(
|
||||
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
|
||||
"T".to_string(),
|
||||
0,
|
||||
);
|
||||
|
||||
// Build cache at width 24
|
||||
pv.ensure_wrapped(24);
|
||||
let w1 = pv.cached();
|
||||
assert!(!w1.is_empty(), "expected wrapped output to be non-empty");
|
||||
let ptr1 = w1.as_ptr();
|
||||
assert_eq!(pv.content_height(80), 5);
|
||||
}
|
||||
|
||||
// Re-run with same width: cache should be reused (pointer stability heuristic)
|
||||
pv.ensure_wrapped(24);
|
||||
let w2 = pv.cached();
|
||||
let ptr2 = w2.as_ptr();
|
||||
assert_eq!(ptr1, ptr2, "cache should not rebuild for unchanged width");
|
||||
#[test]
|
||||
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
|
||||
let mut pv = PagerView::new(
|
||||
vec![
|
||||
paragraph_block("a", 1),
|
||||
paragraph_block("b", 3),
|
||||
paragraph_block("c", 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 8);
|
||||
|
||||
// Change width: cache should rebuild and likely produce different length
|
||||
// Drop immutable borrow before mutating
|
||||
let prev_len = w2.len();
|
||||
pv.ensure_wrapped(36);
|
||||
let w3 = pv.cached();
|
||||
assert_ne!(
|
||||
prev_len,
|
||||
w3.len(),
|
||||
"wrapped length should change on width change"
|
||||
pv.scroll_offset = 0;
|
||||
let content_area = pv.content_area(area);
|
||||
pv.ensure_chunk_visible(2, content_area);
|
||||
|
||||
let mut buf = Buffer::empty(area);
|
||||
pv.render(area, &mut buf);
|
||||
let rendered = buffer_to_text(&buf, area);
|
||||
|
||||
assert!(
|
||||
rendered.contains("c0"),
|
||||
"expected chunk top in view: {rendered:?}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("c1"),
|
||||
"expected chunk middle in view: {rendered:?}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("c2"),
|
||||
"expected chunk bottom in view: {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pager_wrap_cache_invalidates_on_append() {
|
||||
let long = "Another long line for wrapping behavior verification.";
|
||||
let mut pv = PagerView::new(vec![Text::from(vec![long.into()])], "T".to_string(), 0);
|
||||
pv.ensure_wrapped(28);
|
||||
let w1 = pv.cached();
|
||||
let len1 = w1.len();
|
||||
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
|
||||
let mut pv = PagerView::new(
|
||||
vec![
|
||||
paragraph_block("a", 2),
|
||||
paragraph_block("b", 3),
|
||||
paragraph_block("c", 3),
|
||||
],
|
||||
"T".to_string(),
|
||||
0,
|
||||
);
|
||||
let area = Rect::new(0, 0, 20, 3);
|
||||
|
||||
pv.scroll_offset = 6;
|
||||
pv.ensure_chunk_visible(0, area);
|
||||
|
||||
assert_eq!(pv.scroll_offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
|
||||
let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 0);
|
||||
let area = Rect::new(0, 0, 20, 8);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
pv.render(area, &mut buf);
|
||||
|
||||
// Append new lines should cause ensure_wrapped to rebuild due to len change
|
||||
pv.texts.push(Text::from(vec![long.into()]));
|
||||
pv.texts.push(Text::from(vec![long.into()]));
|
||||
pv.ensure_wrapped(28);
|
||||
let w2 = pv.cached();
|
||||
assert!(
|
||||
w2.len() >= len1,
|
||||
"wrapped length should grow or stay same after append"
|
||||
!pv.is_scrolled_to_bottom(),
|
||||
"expected view to report not at bottom when offset < max"
|
||||
);
|
||||
|
||||
pv.scroll_offset = usize::MAX;
|
||||
pv.render(area, &mut buf);
|
||||
|
||||
assert!(
|
||||
pv.is_scrolled_to_bottom(),
|
||||
"expected view to report at bottom after scrolling to end"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,47 @@
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
pub mod highlight;
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
102
codex-rs/tui/src/render/renderable.rs
Normal file
102
codex-rs/tui/src/render/renderable.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
source: tui/src/diff_render.rs
|
||||
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 "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
assertion_line: 721
|
||||
expression: snapshot
|
||||
---
|
||||
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
|
||||
• Proposed Change foo.txt (+2 -0)
|
||||
• Added foo.txt (+2 -0)
|
||||
1 +hello
|
||||
2 +world
|
||||
|
||||
• Change Approved foo.txt (+2 -0)
|
||||
|
||||
✓ Patch applied
|
||||
• Added foo.txt (+2 -0)
|
||||
1 +hello
|
||||
2 +world
|
||||
─────────────────────────────────────────────────────────────────────────── 0% ─
|
||||
↑/↓ scroll PgUp/PgDn page Home/End jump
|
||||
q quit Esc edit prev
|
||||
|
||||
@@ -17,7 +17,6 @@ use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
|
||||
pub(crate) struct StatusIndicatorWidget {
|
||||
/// Animated header text (defaults to "Working").
|
||||
@@ -160,7 +159,7 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
let pretty_elapsed = fmt_elapsed_compact(elapsed);
|
||||
|
||||
// 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(vec![
|
||||
" ".into(),
|
||||
|
||||
Reference in New Issue
Block a user