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_backtrack::BacktrackState;
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
|
use crate::bottom_pane::ApprovalRequest;
|
||||||
use crate::chatwidget::ChatWidget;
|
use crate::chatwidget::ChatWidget;
|
||||||
|
use crate::diff_render::DiffSummary;
|
||||||
|
use crate::exec_command::strip_bash_lc_and_escape;
|
||||||
use crate::file_search::FileSearchManager;
|
use crate::file_search::FileSearchManager;
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
use crate::pager_overlay::Overlay;
|
use crate::pager_overlay::Overlay;
|
||||||
|
use crate::render::highlight::highlight_bash_to_lines;
|
||||||
use crate::resume_picker::ResumeSelection;
|
use crate::resume_picker::ResumeSelection;
|
||||||
use crate::tui;
|
use crate::tui;
|
||||||
use crate::tui::TuiEvent;
|
use crate::tui::TuiEvent;
|
||||||
@@ -292,7 +296,7 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
text.lines().map(ansi_escape_line).collect()
|
text.lines().map(ansi_escape_line).collect()
|
||||||
};
|
};
|
||||||
self.overlay = Some(Overlay::new_static_with_title(
|
self.overlay = Some(Overlay::new_static_with_lines(
|
||||||
pager_lines,
|
pager_lines,
|
||||||
"D I F F".to_string(),
|
"D I F F".to_string(),
|
||||||
));
|
));
|
||||||
@@ -324,12 +328,18 @@ impl App {
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
if let Some(profile) = profile {
|
if let Some(profile) = profile {
|
||||||
self.chat_widget.add_info_message(
|
self.chat_widget.add_info_message(
|
||||||
format!("Model changed to {model} for {profile} profile"),
|
format!("Model changed to {model}{reasoning_effort} for {profile} profile", reasoning_effort = effort.map(|e| format!(" {e}")).unwrap_or_default()),
|
||||||
None,
|
None,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.chat_widget
|
self.chat_widget.add_info_message(
|
||||||
.add_info_message(format!("Model changed to {model}"), None);
|
format!(
|
||||||
|
"Model changed to {model}{reasoning_effort}",
|
||||||
|
reasoning_effort =
|
||||||
|
effort.map(|e| format!(" {e}")).unwrap_or_default()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -363,6 +373,25 @@ impl App {
|
|||||||
AppEvent::OpenReviewCustomPrompt => {
|
AppEvent::OpenReviewCustomPrompt => {
|
||||||
self.chat_widget.show_review_custom_prompt();
|
self.chat_widget.show_review_custom_prompt();
|
||||||
}
|
}
|
||||||
|
AppEvent::FullScreenApprovalRequest(request) => match request {
|
||||||
|
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
|
||||||
|
let _ = tui.enter_alt_screen();
|
||||||
|
let diff_summary = DiffSummary::new(changes, cwd);
|
||||||
|
self.overlay = Some(Overlay::new_static_with_renderables(
|
||||||
|
vec![diff_summary.into()],
|
||||||
|
"P A T C H".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ApprovalRequest::Exec { command, .. } => {
|
||||||
|
let _ = tui.enter_alt_screen();
|
||||||
|
let full_cmd = strip_bash_lc_and_escape(&command);
|
||||||
|
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
||||||
|
self.overlay = Some(Overlay::new_static_with_lines(
|
||||||
|
full_cmd_lines,
|
||||||
|
"E X E C".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use codex_core::protocol::ConversationPathResponseEvent;
|
|||||||
use codex_core::protocol::Event;
|
use codex_core::protocol::Event;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
|
|
||||||
|
use crate::bottom_pane::ApprovalRequest;
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
|
|
||||||
use codex_core::protocol::AskForApproval;
|
use codex_core::protocol::AskForApproval;
|
||||||
@@ -76,4 +77,7 @@ pub(crate) enum AppEvent {
|
|||||||
|
|
||||||
/// Open the custom prompt option from the review popup.
|
/// Open the custom prompt option from the review popup.
|
||||||
OpenReviewCustomPrompt,
|
OpenReviewCustomPrompt,
|
||||||
|
|
||||||
|
/// Open the approval popup.
|
||||||
|
FullScreenApprovalRequest(ApprovalRequest),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::bottom_pane::BottomPaneView;
|
use crate::bottom_pane::BottomPaneView;
|
||||||
use crate::bottom_pane::CancellationEvent;
|
use crate::bottom_pane::CancellationEvent;
|
||||||
use crate::bottom_pane::list_selection_view::HeaderLine;
|
|
||||||
use crate::bottom_pane::list_selection_view::ListSelectionView;
|
use crate::bottom_pane::list_selection_view::ListSelectionView;
|
||||||
use crate::bottom_pane::list_selection_view::SelectionItem;
|
use crate::bottom_pane::list_selection_view::SelectionItem;
|
||||||
use crate::bottom_pane::list_selection_view::SelectionViewParams;
|
use crate::bottom_pane::list_selection_view::SelectionViewParams;
|
||||||
|
use crate::diff_render::DiffSummary;
|
||||||
use crate::exec_command::strip_bash_lc_and_escape;
|
use crate::exec_command::strip_bash_lc_and_escape;
|
||||||
use crate::history_cell;
|
use crate::history_cell;
|
||||||
|
use crate::render::highlight::highlight_bash_to_lines;
|
||||||
|
use crate::render::renderable::ColumnRenderable;
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
use crate::text_formatting::truncate_text;
|
use crate::text_formatting::truncate_text;
|
||||||
|
use codex_core::protocol::FileChange;
|
||||||
use codex_core::protocol::Op;
|
use codex_core::protocol::Op;
|
||||||
use codex_core::protocol::ReviewDecision;
|
use codex_core::protocol::ReviewDecision;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
@@ -22,8 +27,11 @@ use ratatui::layout::Rect;
|
|||||||
use ratatui::style::Stylize;
|
use ratatui::style::Stylize;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
|
use ratatui::widgets::Wrap;
|
||||||
|
|
||||||
/// Request coming from the agent that needs user approval.
|
/// Request coming from the agent that needs user approval.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) enum ApprovalRequest {
|
pub(crate) enum ApprovalRequest {
|
||||||
Exec {
|
Exec {
|
||||||
id: String,
|
id: String,
|
||||||
@@ -33,13 +41,15 @@ pub(crate) enum ApprovalRequest {
|
|||||||
ApplyPatch {
|
ApplyPatch {
|
||||||
id: String,
|
id: String,
|
||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
grant_root: Option<PathBuf>,
|
cwd: PathBuf,
|
||||||
|
changes: HashMap<PathBuf, FileChange>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modal overlay asking the user to approve or deny one or more requests.
|
/// Modal overlay asking the user to approve or deny one or more requests.
|
||||||
pub(crate) struct ApprovalOverlay {
|
pub(crate) struct ApprovalOverlay {
|
||||||
current: Option<ApprovalRequestState>,
|
current_request: Option<ApprovalRequest>,
|
||||||
|
current_variant: Option<ApprovalVariant>,
|
||||||
queue: Vec<ApprovalRequest>,
|
queue: Vec<ApprovalRequest>,
|
||||||
app_event_tx: AppEventSender,
|
app_event_tx: AppEventSender,
|
||||||
list: ListSelectionView,
|
list: ListSelectionView,
|
||||||
@@ -51,23 +61,16 @@ pub(crate) struct ApprovalOverlay {
|
|||||||
impl ApprovalOverlay {
|
impl ApprovalOverlay {
|
||||||
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||||
let mut view = Self {
|
let mut view = Self {
|
||||||
current: Some(ApprovalRequestState::from(request)),
|
current_request: None,
|
||||||
|
current_variant: None,
|
||||||
queue: Vec::new(),
|
queue: Vec::new(),
|
||||||
app_event_tx: app_event_tx.clone(),
|
app_event_tx: app_event_tx.clone(),
|
||||||
list: ListSelectionView::new(
|
list: ListSelectionView::new(Default::default(), app_event_tx),
|
||||||
SelectionViewParams {
|
|
||||||
title: String::new(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
app_event_tx,
|
|
||||||
),
|
|
||||||
options: Vec::new(),
|
options: Vec::new(),
|
||||||
current_complete: false,
|
current_complete: false,
|
||||||
done: false,
|
done: false,
|
||||||
};
|
};
|
||||||
let (options, params) = view.build_options();
|
view.set_current(request);
|
||||||
view.options = options;
|
|
||||||
view.list = ListSelectionView::new(params, view.app_event_tx.clone());
|
|
||||||
view
|
view
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,28 +79,30 @@ impl ApprovalOverlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_current(&mut self, request: ApprovalRequest) {
|
fn set_current(&mut self, request: ApprovalRequest) {
|
||||||
self.current = Some(ApprovalRequestState::from(request));
|
self.current_request = Some(request.clone());
|
||||||
|
let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request);
|
||||||
|
self.current_variant = Some(variant.clone());
|
||||||
self.current_complete = false;
|
self.current_complete = false;
|
||||||
let (options, params) = self.build_options();
|
let (options, params) = Self::build_options(variant, header);
|
||||||
self.options = options;
|
self.options = options;
|
||||||
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
|
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_options(&self) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
fn build_options(
|
||||||
let Some(state) = self.current.as_ref() else {
|
variant: ApprovalVariant,
|
||||||
return (
|
header: Box<dyn Renderable>,
|
||||||
Vec::new(),
|
) -> (Vec<ApprovalOption>, SelectionViewParams) {
|
||||||
SelectionViewParams {
|
let (options, title) = match &variant {
|
||||||
title: String::new(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
let (options, title) = match &state.variant {
|
|
||||||
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
|
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
|
||||||
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
|
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let header = Box::new(ColumnRenderable::new([
|
||||||
|
Box::new(Line::from(title.bold())),
|
||||||
|
Box::new(Line::from("")),
|
||||||
|
header,
|
||||||
|
]));
|
||||||
|
|
||||||
let items = options
|
let items = options
|
||||||
.iter()
|
.iter()
|
||||||
.map(|opt| SelectionItem {
|
.map(|opt| SelectionItem {
|
||||||
@@ -111,10 +116,9 @@ impl ApprovalOverlay {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let params = SelectionViewParams {
|
let params = SelectionViewParams {
|
||||||
title,
|
|
||||||
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
|
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
|
||||||
items,
|
items,
|
||||||
header: state.header.clone(),
|
header,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,8 +132,8 @@ impl ApprovalOverlay {
|
|||||||
let Some(option) = self.options.get(actual_idx) else {
|
let Some(option) = self.options.get(actual_idx) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(state) = self.current.as_ref() {
|
if let Some(variant) = self.current_variant.as_ref() {
|
||||||
match (&state.variant, option.decision) {
|
match (&variant, option.decision) {
|
||||||
(ApprovalVariant::Exec { id, command }, decision) => {
|
(ApprovalVariant::Exec { id, command }, decision) => {
|
||||||
self.handle_exec_decision(id, command, decision);
|
self.handle_exec_decision(id, command, decision);
|
||||||
}
|
}
|
||||||
@@ -171,30 +175,43 @@ impl ApprovalOverlay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
|
||||||
if key_event.kind != KeyEventKind::Press {
|
match key_event {
|
||||||
return false;
|
KeyEvent {
|
||||||
}
|
kind: KeyEventKind::Press,
|
||||||
let KeyEvent {
|
code: KeyCode::Char('a'),
|
||||||
code: KeyCode::Char(c),
|
modifiers,
|
||||||
modifiers,
|
..
|
||||||
..
|
} if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
} = key_event
|
if let Some(request) = self.current_request.as_ref() {
|
||||||
else {
|
self.app_event_tx
|
||||||
return false;
|
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
|
||||||
};
|
true
|
||||||
if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) {
|
} else {
|
||||||
return false;
|
false
|
||||||
}
|
}
|
||||||
let lower = c.to_ascii_lowercase();
|
}
|
||||||
if let Some(idx) = self
|
KeyEvent {
|
||||||
.options
|
kind: KeyEventKind::Press,
|
||||||
.iter()
|
code: KeyCode::Char(c),
|
||||||
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
|
modifiers,
|
||||||
{
|
..
|
||||||
self.apply_selection(idx);
|
} if !modifiers.contains(KeyModifiers::CONTROL)
|
||||||
true
|
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||||
} else {
|
{
|
||||||
false
|
let lower = c.to_ascii_lowercase();
|
||||||
|
match self
|
||||||
|
.options
|
||||||
|
.iter()
|
||||||
|
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
|
||||||
|
{
|
||||||
|
Some(idx) => {
|
||||||
|
self.apply_selection(idx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,9 +232,9 @@ impl BottomPaneView for ApprovalOverlay {
|
|||||||
return CancellationEvent::Handled;
|
return CancellationEvent::Handled;
|
||||||
}
|
}
|
||||||
if !self.current_complete
|
if !self.current_complete
|
||||||
&& let Some(state) = self.current.as_ref()
|
&& let Some(variant) = self.current_variant.as_ref()
|
||||||
{
|
{
|
||||||
match &state.variant {
|
match &variant {
|
||||||
ApprovalVariant::Exec { id, command } => {
|
ApprovalVariant::Exec { id, command } => {
|
||||||
self.handle_exec_decision(id, command, ReviewDecision::Abort);
|
self.handle_exec_decision(id, command, ReviewDecision::Abort);
|
||||||
}
|
}
|
||||||
@@ -235,14 +252,6 @@ impl BottomPaneView for ApprovalOverlay {
|
|||||||
self.done
|
self.done
|
||||||
}
|
}
|
||||||
|
|
||||||
fn desired_height(&self, width: u16) -> u16 {
|
|
||||||
self.list.desired_height(width)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
|
||||||
self.list.render(area, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_consume_approval_request(
|
fn try_consume_approval_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
request: ApprovalRequest,
|
request: ApprovalRequest,
|
||||||
@@ -256,9 +265,19 @@ impl BottomPaneView for ApprovalOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Renderable for ApprovalOverlay {
|
||||||
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
|
self.list.desired_height(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
self.list.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ApprovalRequestState {
|
struct ApprovalRequestState {
|
||||||
variant: ApprovalVariant,
|
variant: ApprovalVariant,
|
||||||
header: Vec<HeaderLine>,
|
header: Box<dyn Renderable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ApprovalRequest> for ApprovalRequestState {
|
impl From<ApprovalRequest> for ApprovalRequestState {
|
||||||
@@ -269,63 +288,50 @@ impl From<ApprovalRequest> for ApprovalRequestState {
|
|||||||
command,
|
command,
|
||||||
reason,
|
reason,
|
||||||
} => {
|
} => {
|
||||||
let mut header = Vec::new();
|
let mut header: Vec<Line<'static>> = Vec::new();
|
||||||
if let Some(reason) = reason
|
if let Some(reason) = reason
|
||||||
&& !reason.is_empty()
|
&& !reason.is_empty()
|
||||||
{
|
{
|
||||||
header.push(HeaderLine::Text {
|
header.push(reason.italic().into());
|
||||||
text: reason,
|
header.push(Line::from(""));
|
||||||
italic: true,
|
|
||||||
});
|
|
||||||
header.push(HeaderLine::Spacer);
|
|
||||||
}
|
}
|
||||||
let command_snippet = exec_snippet(&command);
|
let full_cmd = strip_bash_lc_and_escape(&command);
|
||||||
if !command_snippet.is_empty() {
|
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
|
||||||
header.push(HeaderLine::Text {
|
if let Some(first) = full_cmd_lines.first_mut() {
|
||||||
text: format!("Command: {command_snippet}"),
|
first.spans.insert(0, Span::from("$ "));
|
||||||
italic: false,
|
|
||||||
});
|
|
||||||
header.push(HeaderLine::Spacer);
|
|
||||||
}
|
}
|
||||||
|
header.extend(full_cmd_lines);
|
||||||
Self {
|
Self {
|
||||||
variant: ApprovalVariant::Exec { id, command },
|
variant: ApprovalVariant::Exec { id, command },
|
||||||
header,
|
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ApprovalRequest::ApplyPatch {
|
ApprovalRequest::ApplyPatch {
|
||||||
id,
|
id,
|
||||||
reason,
|
reason,
|
||||||
grant_root,
|
cwd,
|
||||||
|
changes,
|
||||||
} => {
|
} => {
|
||||||
let mut header = Vec::new();
|
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
|
||||||
|
header.push(DiffSummary::new(changes, cwd).into());
|
||||||
if let Some(reason) = reason
|
if let Some(reason) = reason
|
||||||
&& !reason.is_empty()
|
&& !reason.is_empty()
|
||||||
{
|
{
|
||||||
header.push(HeaderLine::Text {
|
header.push(Box::new(Line::from("")));
|
||||||
text: reason,
|
header.push(Box::new(
|
||||||
italic: true,
|
Paragraph::new(reason.italic()).wrap(Wrap { trim: false }),
|
||||||
});
|
));
|
||||||
header.push(HeaderLine::Spacer);
|
|
||||||
}
|
|
||||||
if let Some(root) = grant_root {
|
|
||||||
header.push(HeaderLine::Text {
|
|
||||||
text: format!(
|
|
||||||
"Grant write access to {} for the remainder of this session.",
|
|
||||||
root.display()
|
|
||||||
),
|
|
||||||
italic: false,
|
|
||||||
});
|
|
||||||
header.push(HeaderLine::Spacer);
|
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
variant: ApprovalVariant::ApplyPatch { id },
|
variant: ApprovalVariant::ApplyPatch { id },
|
||||||
header,
|
header: Box::new(ColumnRenderable::new(header)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
enum ApprovalVariant {
|
enum ApprovalVariant {
|
||||||
Exec { id: String, command: Vec<String> },
|
Exec { id: String, command: Vec<String> },
|
||||||
ApplyPatch { id: String },
|
ApplyPatch { id: String },
|
||||||
@@ -343,20 +349,20 @@ fn exec_options() -> Vec<ApprovalOption> {
|
|||||||
vec![
|
vec![
|
||||||
ApprovalOption {
|
ApprovalOption {
|
||||||
label: "Approve and run now".to_string(),
|
label: "Approve and run now".to_string(),
|
||||||
description: "(Y) Run this command one time".to_string(),
|
description: "Run this command one time".to_string(),
|
||||||
decision: ReviewDecision::Approved,
|
decision: ReviewDecision::Approved,
|
||||||
shortcut: Some('y'),
|
shortcut: Some('y'),
|
||||||
},
|
},
|
||||||
ApprovalOption {
|
ApprovalOption {
|
||||||
label: "Always approve this session".to_string(),
|
label: "Always approve this session".to_string(),
|
||||||
description: "(A) Automatically approve this command for the rest of the session"
|
description: "Automatically approve this command for the rest of the session"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
decision: ReviewDecision::ApprovedForSession,
|
decision: ReviewDecision::ApprovedForSession,
|
||||||
shortcut: Some('a'),
|
shortcut: Some('a'),
|
||||||
},
|
},
|
||||||
ApprovalOption {
|
ApprovalOption {
|
||||||
label: "Cancel".to_string(),
|
label: "Cancel".to_string(),
|
||||||
description: "(N) Do not run the command".to_string(),
|
description: "Do not run the command".to_string(),
|
||||||
decision: ReviewDecision::Abort,
|
decision: ReviewDecision::Abort,
|
||||||
shortcut: Some('n'),
|
shortcut: Some('n'),
|
||||||
},
|
},
|
||||||
@@ -367,13 +373,13 @@ fn patch_options() -> Vec<ApprovalOption> {
|
|||||||
vec![
|
vec![
|
||||||
ApprovalOption {
|
ApprovalOption {
|
||||||
label: "Approve".to_string(),
|
label: "Approve".to_string(),
|
||||||
description: "(Y) Apply the proposed changes".to_string(),
|
description: "Apply the proposed changes".to_string(),
|
||||||
decision: ReviewDecision::Approved,
|
decision: ReviewDecision::Approved,
|
||||||
shortcut: Some('y'),
|
shortcut: Some('y'),
|
||||||
},
|
},
|
||||||
ApprovalOption {
|
ApprovalOption {
|
||||||
label: "Cancel".to_string(),
|
label: "Cancel".to_string(),
|
||||||
description: "(N) Do not apply the changes".to_string(),
|
description: "Do not apply the changes".to_string(),
|
||||||
decision: ReviewDecision::Abort,
|
decision: ReviewDecision::Abort,
|
||||||
shortcut: Some('n'),
|
shortcut: Some('n'),
|
||||||
},
|
},
|
||||||
@@ -516,8 +522,8 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let view = ApprovalOverlay::new(exec_request, tx);
|
let view = ApprovalOverlay::new(exec_request, tx);
|
||||||
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6));
|
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
|
||||||
view.render(Rect::new(0, 0, 80, 6), &mut buf);
|
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
|
||||||
|
|
||||||
let rendered: Vec<String> = (0..buf.area.height)
|
let rendered: Vec<String> = (0..buf.area.height)
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
@@ -529,7 +535,7 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
rendered
|
rendered
|
||||||
.iter()
|
.iter()
|
||||||
.any(|line| line.contains("Command: echo hello world")),
|
.any(|line| line.contains("echo hello world")),
|
||||||
"expected header to include command snippet, got {rendered:?}"
|
"expected header to include command snippet, got {rendered:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use crate::bottom_pane::ApprovalRequest;
|
use crate::bottom_pane::ApprovalRequest;
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use ratatui::buffer::Buffer;
|
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
|
|
||||||
use super::CancellationEvent;
|
use super::CancellationEvent;
|
||||||
|
|
||||||
/// Trait implemented by every view that can be shown in the bottom pane.
|
/// Trait implemented by every view that can be shown in the bottom pane.
|
||||||
pub(crate) trait BottomPaneView {
|
pub(crate) trait BottomPaneView: Renderable {
|
||||||
/// Handle a key event while the view is active. A redraw is always
|
/// Handle a key event while the view is active. A redraw is always
|
||||||
/// scheduled after this call.
|
/// scheduled after this call.
|
||||||
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
|
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
|
||||||
@@ -21,12 +21,6 @@ pub(crate) trait BottomPaneView {
|
|||||||
CancellationEvent::NotHandled
|
CancellationEvent::NotHandled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the desired height of the view.
|
|
||||||
fn desired_height(&self, width: u16) -> u16;
|
|
||||||
|
|
||||||
/// Render the view: this will be displayed in place of the composer.
|
|
||||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
|
||||||
|
|
||||||
/// Optional paste handler. Return true if the view modified its state and
|
/// Optional paste handler. Return true if the view modified its state and
|
||||||
/// needs a redraw.
|
/// needs a redraw.
|
||||||
fn handle_paste(&mut self, _pasted: String) -> bool {
|
fn handle_paste(&mut self, _pasted: String) -> bool {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use super::popup_consts::MAX_POPUP_ROWS;
|
|||||||
use super::scroll_state::ScrollState;
|
use super::scroll_state::ScrollState;
|
||||||
use super::selection_popup_common::GenericDisplayRow;
|
use super::selection_popup_common::GenericDisplayRow;
|
||||||
use super::selection_popup_common::render_rows;
|
use super::selection_popup_common::render_rows;
|
||||||
|
use crate::render::Insets;
|
||||||
|
use crate::render::RectExt;
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
use crate::slash_command::built_in_slash_commands;
|
use crate::slash_command::built_in_slash_commands;
|
||||||
use codex_common::fuzzy_match::fuzzy_match;
|
use codex_common::fuzzy_match::fuzzy_match;
|
||||||
@@ -205,13 +207,12 @@ impl WidgetRef for CommandPopup {
|
|||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let rows = self.rows_from_matches(self.filtered());
|
let rows = self.rows_from_matches(self.filtered());
|
||||||
render_rows(
|
render_rows(
|
||||||
area,
|
area.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||||
buf,
|
buf,
|
||||||
&rows,
|
&rows,
|
||||||
&self.state,
|
&self.state,
|
||||||
MAX_POPUP_ROWS,
|
MAX_POPUP_ROWS,
|
||||||
"no matches",
|
"no matches",
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ use ratatui::widgets::StatefulWidgetRef;
|
|||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
|
|
||||||
use super::popup_consts::STANDARD_POPUP_HINT_LINE;
|
use super::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||||
|
|
||||||
use super::CancellationEvent;
|
use super::CancellationEvent;
|
||||||
@@ -94,6 +96,36 @@ impl BottomPaneView for CustomPromptView {
|
|||||||
self.complete
|
self.complete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_paste(&mut self, pasted: String) -> bool {
|
||||||
|
if pasted.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.textarea.insert_str(&pasted);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||||
|
if area.height < 2 || area.width <= 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
||||||
|
if text_area_height == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||||
|
let top_line_count = 1u16 + extra_offset;
|
||||||
|
let textarea_rect = Rect {
|
||||||
|
x: area.x.saturating_add(2),
|
||||||
|
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
||||||
|
width: area.width.saturating_sub(2),
|
||||||
|
height: text_area_height,
|
||||||
|
};
|
||||||
|
let state = *self.textarea_state.borrow();
|
||||||
|
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for CustomPromptView {
|
||||||
fn desired_height(&self, width: u16) -> u16 {
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
||||||
1u16 + extra_top + self.input_height(width) + 3u16
|
1u16 + extra_top + self.input_height(width) + 3u16
|
||||||
@@ -200,34 +232,6 @@ impl BottomPaneView for CustomPromptView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_paste(&mut self, pasted: String) -> bool {
|
|
||||||
if pasted.is_empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
self.textarea.insert_str(&pasted);
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
|
||||||
if area.height < 2 || area.width <= 2 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let text_area_height = self.input_height(area.width).saturating_sub(1);
|
|
||||||
if text_area_height == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
|
|
||||||
let top_line_count = 1u16 + extra_offset;
|
|
||||||
let textarea_rect = Rect {
|
|
||||||
x: area.x.saturating_add(2),
|
|
||||||
y: area.y.saturating_add(top_line_count).saturating_add(1),
|
|
||||||
width: area.width.saturating_sub(2),
|
|
||||||
height: text_area_height,
|
|
||||||
};
|
|
||||||
let state = *self.textarea_state.borrow();
|
|
||||||
self.textarea.cursor_pos_with_state(textarea_rect, state)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CustomPromptView {
|
impl CustomPromptView {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ use ratatui::buffer::Buffer;
|
|||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
|
||||||
|
use crate::render::Insets;
|
||||||
|
use crate::render::RectExt;
|
||||||
|
|
||||||
use super::popup_consts::MAX_POPUP_ROWS;
|
use super::popup_consts::MAX_POPUP_ROWS;
|
||||||
use super::scroll_state::ScrollState;
|
use super::scroll_state::ScrollState;
|
||||||
use super::selection_popup_common::GenericDisplayRow;
|
use super::selection_popup_common::GenericDisplayRow;
|
||||||
@@ -139,13 +142,12 @@ impl WidgetRef for &FileSearchPopup {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render_rows(
|
render_rows(
|
||||||
area,
|
area.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||||
buf,
|
buf,
|
||||||
&rows_all,
|
&rows_all,
|
||||||
&self.state,
|
&self.state,
|
||||||
MAX_POPUP_ROWS,
|
MAX_POPUP_ROWS,
|
||||||
empty_message,
|
empty_message,
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ use crossterm::event::KeyCode;
|
|||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use crossterm::event::KeyModifiers;
|
use crossterm::event::KeyModifiers;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Constraint;
|
||||||
|
use ratatui::layout::Layout;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::Stylize;
|
use ratatui::style::Stylize;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
|
use ratatui::widgets::Block;
|
||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
use textwrap::wrap;
|
|
||||||
|
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
|
use crate::render::Insets;
|
||||||
|
use crate::render::RectExt as _;
|
||||||
|
use crate::render::renderable::ColumnRenderable;
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
|
use crate::style::user_message_style;
|
||||||
|
use crate::terminal_palette;
|
||||||
|
|
||||||
use super::CancellationEvent;
|
use super::CancellationEvent;
|
||||||
use super::bottom_pane_view::BottomPaneView;
|
use super::bottom_pane_view::BottomPaneView;
|
||||||
@@ -23,12 +31,6 @@ use super::selection_popup_common::render_rows;
|
|||||||
/// One selectable item in the generic selection list.
|
/// One selectable item in the generic selection list.
|
||||||
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub(crate) enum HeaderLine {
|
|
||||||
Text { text: String, italic: bool },
|
|
||||||
Spacer,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct SelectionItem {
|
pub(crate) struct SelectionItem {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
@@ -38,20 +40,31 @@ pub(crate) struct SelectionItem {
|
|||||||
pub search_value: Option<String>,
|
pub search_value: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct SelectionViewParams {
|
pub(crate) struct SelectionViewParams {
|
||||||
pub title: String,
|
pub title: Option<String>,
|
||||||
pub subtitle: Option<String>,
|
pub subtitle: Option<String>,
|
||||||
pub footer_hint: Option<String>,
|
pub footer_hint: Option<String>,
|
||||||
pub items: Vec<SelectionItem>,
|
pub items: Vec<SelectionItem>,
|
||||||
pub is_searchable: bool,
|
pub is_searchable: bool,
|
||||||
pub search_placeholder: Option<String>,
|
pub search_placeholder: Option<String>,
|
||||||
pub header: Vec<HeaderLine>,
|
pub header: Box<dyn Renderable>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SelectionViewParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
title: None,
|
||||||
|
subtitle: None,
|
||||||
|
footer_hint: None,
|
||||||
|
items: Vec::new(),
|
||||||
|
is_searchable: false,
|
||||||
|
search_placeholder: None,
|
||||||
|
header: Box::new(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct ListSelectionView {
|
pub(crate) struct ListSelectionView {
|
||||||
title: String,
|
|
||||||
subtitle: Option<String>,
|
|
||||||
footer_hint: Option<String>,
|
footer_hint: Option<String>,
|
||||||
items: Vec<SelectionItem>,
|
items: Vec<SelectionItem>,
|
||||||
state: ScrollState,
|
state: ScrollState,
|
||||||
@@ -62,23 +75,22 @@ pub(crate) struct ListSelectionView {
|
|||||||
search_placeholder: Option<String>,
|
search_placeholder: Option<String>,
|
||||||
filtered_indices: Vec<usize>,
|
filtered_indices: Vec<usize>,
|
||||||
last_selected_actual_idx: Option<usize>,
|
last_selected_actual_idx: Option<usize>,
|
||||||
header: Vec<HeaderLine>,
|
header: Box<dyn Renderable>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListSelectionView {
|
impl ListSelectionView {
|
||||||
fn dim_prefix_span() -> Span<'static> {
|
|
||||||
"▌ ".dim()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
|
|
||||||
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
|
|
||||||
para.render(area, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
|
||||||
|
let mut header = params.header;
|
||||||
|
if params.title.is_some() || params.subtitle.is_some() {
|
||||||
|
let title = params.title.map(|title| Line::from(title.bold()));
|
||||||
|
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
|
||||||
|
header = Box::new(ColumnRenderable::new([
|
||||||
|
header,
|
||||||
|
Box::new(title),
|
||||||
|
Box::new(subtitle),
|
||||||
|
]));
|
||||||
|
}
|
||||||
let mut s = Self {
|
let mut s = Self {
|
||||||
title: params.title,
|
|
||||||
subtitle: params.subtitle,
|
|
||||||
footer_hint: params.footer_hint,
|
footer_hint: params.footer_hint,
|
||||||
items: params.items,
|
items: params.items,
|
||||||
state: ScrollState::new(),
|
state: ScrollState::new(),
|
||||||
@@ -93,7 +105,7 @@ impl ListSelectionView {
|
|||||||
},
|
},
|
||||||
filtered_indices: Vec::new(),
|
filtered_indices: Vec::new(),
|
||||||
last_selected_actual_idx: None,
|
last_selected_actual_idx: None,
|
||||||
header: params.header,
|
header,
|
||||||
};
|
};
|
||||||
s.apply_filter();
|
s.apply_filter();
|
||||||
s
|
s
|
||||||
@@ -171,7 +183,7 @@ impl ListSelectionView {
|
|||||||
.filter_map(|(visible_idx, actual_idx)| {
|
.filter_map(|(visible_idx, actual_idx)| {
|
||||||
self.items.get(*actual_idx).map(|item| {
|
self.items.get(*actual_idx).map(|item| {
|
||||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||||
let prefix = if is_selected { '>' } else { ' ' };
|
let prefix = if is_selected { '›' } else { ' ' };
|
||||||
let name = item.name.as_str();
|
let name = item.name.as_str();
|
||||||
let name_with_marker = if item.is_current {
|
let name_with_marker = if item.is_current {
|
||||||
format!("{name} (current)")
|
format!("{name} (current)")
|
||||||
@@ -179,7 +191,13 @@ impl ListSelectionView {
|
|||||||
item.name.clone()
|
item.name.clone()
|
||||||
};
|
};
|
||||||
let n = visible_idx + 1;
|
let n = visible_idx + 1;
|
||||||
let display_name = format!("{prefix} {n}. {name_with_marker}");
|
let display_name = if self.is_searchable {
|
||||||
|
// The number keys don't work when search is enabled (since we let the
|
||||||
|
// numbers be used for the search query).
|
||||||
|
format!("{prefix} {name_with_marker}")
|
||||||
|
} else {
|
||||||
|
format!("{prefix} {n}. {name_with_marker}")
|
||||||
|
};
|
||||||
GenericDisplayRow {
|
GenericDisplayRow {
|
||||||
name: display_name,
|
name: display_name,
|
||||||
match_indices: None,
|
match_indices: None,
|
||||||
@@ -231,39 +249,6 @@ impl ListSelectionView {
|
|||||||
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
|
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
|
||||||
self.last_selected_actual_idx.take()
|
self.last_selected_actual_idx.take()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
|
|
||||||
if self.header.is_empty() || width == 0 {
|
|
||||||
return Vec::new();
|
|
||||||
}
|
|
||||||
let prefix_width = Self::dim_prefix_span().width() as u16;
|
|
||||||
let available = width.saturating_sub(prefix_width).max(1) as usize;
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
for entry in &self.header {
|
|
||||||
match entry {
|
|
||||||
HeaderLine::Spacer => lines.push(Vec::new()),
|
|
||||||
HeaderLine::Text { text, italic } => {
|
|
||||||
if text.is_empty() {
|
|
||||||
lines.push(Vec::new());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for part in wrap(text, available) {
|
|
||||||
let span = if *italic {
|
|
||||||
Span::from(part.into_owned()).italic()
|
|
||||||
} else {
|
|
||||||
Span::from(part.into_owned())
|
|
||||||
};
|
|
||||||
lines.push(vec![span]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines
|
|
||||||
}
|
|
||||||
|
|
||||||
fn header_height(&self, width: u16) -> u16 {
|
|
||||||
self.header_spans_for_width(width).len() as u16
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BottomPaneView for ListSelectionView {
|
impl BottomPaneView for ListSelectionView {
|
||||||
@@ -299,6 +284,24 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
self.search_query.push(c);
|
self.search_query.push(c);
|
||||||
self.apply_filter();
|
self.apply_filter();
|
||||||
}
|
}
|
||||||
|
KeyEvent {
|
||||||
|
code: KeyCode::Char(c),
|
||||||
|
modifiers,
|
||||||
|
..
|
||||||
|
} if !self.is_searchable
|
||||||
|
&& !modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||||
|
{
|
||||||
|
if let Some(idx) = c
|
||||||
|
.to_digit(10)
|
||||||
|
.map(|d| d as usize)
|
||||||
|
.and_then(|d| d.checked_sub(1))
|
||||||
|
&& idx < self.items.len()
|
||||||
|
{
|
||||||
|
self.state.selected_idx = Some(idx);
|
||||||
|
self.accept();
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Enter,
|
code: KeyCode::Enter,
|
||||||
modifiers: KeyModifiers::NONE,
|
modifiers: KeyModifiers::NONE,
|
||||||
@@ -316,7 +319,9 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
self.complete = true;
|
self.complete = true;
|
||||||
CancellationEvent::Handled
|
CancellationEvent::Handled
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for ListSelectionView {
|
||||||
fn desired_height(&self, width: u16) -> u16 {
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
||||||
// Build the same display rows used by the renderer so wrapping math matches.
|
// Build the same display rows used by the renderer so wrapping math matches.
|
||||||
@@ -324,19 +329,13 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
|
|
||||||
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
||||||
|
|
||||||
// +1 for the title row, +1 for a spacer line beneath the header,
|
let mut height = self.header.desired_height(width);
|
||||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
height = height.saturating_add(rows_height + 3);
|
||||||
let mut height = self.header_height(width);
|
|
||||||
height = height.saturating_add(rows_height + 2);
|
|
||||||
if self.is_searchable {
|
if self.is_searchable {
|
||||||
height = height.saturating_add(1);
|
height = height.saturating_add(1);
|
||||||
}
|
}
|
||||||
if self.subtitle.is_some() {
|
|
||||||
// +1 for subtitle (the spacer is accounted for above)
|
|
||||||
height = height.saturating_add(1);
|
|
||||||
}
|
|
||||||
if self.footer_hint.is_some() {
|
if self.footer_hint.is_some() {
|
||||||
height = height.saturating_add(2);
|
height = height.saturating_add(1);
|
||||||
}
|
}
|
||||||
height
|
height
|
||||||
}
|
}
|
||||||
@@ -346,52 +345,42 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut next_y = area.y;
|
let [content_area, footer_area] = Layout::vertical([
|
||||||
let header_spans = self.header_spans_for_width(area.width);
|
Constraint::Fill(1),
|
||||||
for spans in header_spans.into_iter() {
|
Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }),
|
||||||
if next_y >= area.y + area.height {
|
])
|
||||||
return;
|
.areas(area);
|
||||||
}
|
|
||||||
let row = Rect {
|
Block::default()
|
||||||
x: area.x,
|
.style(user_message_style(terminal_palette::default_bg()))
|
||||||
y: next_y,
|
.render(content_area, buf);
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
let header_height = self.header.desired_height(content_area.width);
|
||||||
};
|
let rows = self.build_rows();
|
||||||
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
|
let rows_height =
|
||||||
if spans.is_empty() {
|
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, content_area.width);
|
||||||
prefixed.push(String::new().into());
|
let [header_area, _, search_area, list_area] = Layout::vertical([
|
||||||
} else {
|
Constraint::Max(header_height),
|
||||||
prefixed.extend(spans);
|
Constraint::Max(1),
|
||||||
}
|
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
|
||||||
Paragraph::new(Line::from(prefixed)).render(row, buf);
|
Constraint::Length(rows_height),
|
||||||
next_y = next_y.saturating_add(1);
|
])
|
||||||
|
.areas(content_area.inset(Insets::vh(1, 2)));
|
||||||
|
|
||||||
|
if header_area.height < header_height {
|
||||||
|
let [header_area, elision_area] =
|
||||||
|
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
|
||||||
|
self.header.render(header_area, buf);
|
||||||
|
Paragraph::new(vec![
|
||||||
|
Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(),
|
||||||
|
])
|
||||||
|
.render(elision_area, buf);
|
||||||
|
} else {
|
||||||
|
self.header.render(header_area, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
if next_y >= area.y + area.height {
|
if self.is_searchable {
|
||||||
return;
|
Line::from(self.search_query.clone()).render(search_area, buf);
|
||||||
}
|
|
||||||
|
|
||||||
let title_area = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: next_y,
|
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
Paragraph::new(Line::from(vec![
|
|
||||||
Self::dim_prefix_span(),
|
|
||||||
self.title.clone().bold(),
|
|
||||||
]))
|
|
||||||
.render(title_area, buf);
|
|
||||||
next_y = next_y.saturating_add(1);
|
|
||||||
|
|
||||||
if self.is_searchable && next_y < area.y + area.height {
|
|
||||||
let search_area = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: next_y,
|
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
let query_span: Span<'static> = if self.search_query.is_empty() {
|
let query_span: Span<'static> = if self.search_query.is_empty() {
|
||||||
self.search_placeholder
|
self.search_placeholder
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -400,80 +389,40 @@ impl BottomPaneView for ListSelectionView {
|
|||||||
} else {
|
} else {
|
||||||
self.search_query.clone().into()
|
self.search_query.clone().into()
|
||||||
};
|
};
|
||||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
|
Line::from(query_span).render(search_area, buf);
|
||||||
.render(search_area, buf);
|
|
||||||
next_y = next_y.saturating_add(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(sub) = &self.subtitle {
|
if list_area.height > 0 {
|
||||||
if next_y >= area.y + area.height {
|
let list_area = Rect {
|
||||||
return;
|
x: list_area.x - 2,
|
||||||
}
|
y: list_area.y,
|
||||||
let subtitle_area = Rect {
|
width: list_area.width + 2,
|
||||||
x: area.x,
|
height: list_area.height,
|
||||||
y: next_y,
|
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
};
|
||||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
|
|
||||||
.render(subtitle_area, buf);
|
|
||||||
next_y = next_y.saturating_add(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if next_y >= area.y + area.height {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let spacer_area = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: next_y,
|
|
||||||
width: area.width,
|
|
||||||
height: 1,
|
|
||||||
};
|
|
||||||
Self::render_dim_prefix_line(spacer_area, buf);
|
|
||||||
next_y = next_y.saturating_add(1);
|
|
||||||
|
|
||||||
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
|
|
||||||
if next_y >= area.y + area.height {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let rows_area = Rect {
|
|
||||||
x: area.x,
|
|
||||||
y: next_y,
|
|
||||||
width: area.width,
|
|
||||||
height: area
|
|
||||||
.height
|
|
||||||
.saturating_sub(next_y.saturating_sub(area.y))
|
|
||||||
.saturating_sub(footer_reserved),
|
|
||||||
};
|
|
||||||
|
|
||||||
let rows = self.build_rows();
|
|
||||||
if rows_area.height > 0 {
|
|
||||||
render_rows(
|
render_rows(
|
||||||
rows_area,
|
list_area,
|
||||||
buf,
|
buf,
|
||||||
&rows,
|
&rows,
|
||||||
&self.state,
|
&self.state,
|
||||||
MAX_POPUP_ROWS,
|
list_area.height as usize,
|
||||||
"no matches",
|
"no matches",
|
||||||
true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(hint) = &self.footer_hint {
|
if let Some(hint) = &self.footer_hint {
|
||||||
let footer_area = Rect {
|
let hint_area = Rect {
|
||||||
x: area.x,
|
x: footer_area.x + 2,
|
||||||
y: area.y + area.height - 1,
|
y: footer_area.y,
|
||||||
width: area.width,
|
width: footer_area.width.saturating_sub(2),
|
||||||
height: 1,
|
height: footer_area.height,
|
||||||
};
|
};
|
||||||
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
|
Line::from(hint.clone().dim()).render(hint_area, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::BottomPaneView;
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
|
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
|
||||||
@@ -504,7 +453,7 @@ mod tests {
|
|||||||
];
|
];
|
||||||
ListSelectionView::new(
|
ListSelectionView::new(
|
||||||
SelectionViewParams {
|
SelectionViewParams {
|
||||||
title: "Select Approval Mode".to_string(),
|
title: Some("Select Approval Mode".to_string()),
|
||||||
subtitle: subtitle.map(str::to_string),
|
subtitle: subtitle.map(str::to_string),
|
||||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
items,
|
items,
|
||||||
@@ -516,7 +465,7 @@ mod tests {
|
|||||||
|
|
||||||
fn render_lines(view: &ListSelectionView) -> String {
|
fn render_lines(view: &ListSelectionView) -> String {
|
||||||
let width = 48;
|
let width = 48;
|
||||||
let height = BottomPaneView::desired_height(view, width);
|
let height = view.desired_height(width);
|
||||||
let area = Rect::new(0, 0, width, height);
|
let area = Rect::new(0, 0, width, height);
|
||||||
let mut buf = Buffer::empty(area);
|
let mut buf = Buffer::empty(area);
|
||||||
view.render(area, &mut buf);
|
view.render(area, &mut buf);
|
||||||
@@ -567,7 +516,7 @@ mod tests {
|
|||||||
}];
|
}];
|
||||||
let mut view = ListSelectionView::new(
|
let mut view = ListSelectionView::new(
|
||||||
SelectionViewParams {
|
SelectionViewParams {
|
||||||
title: "Select Approval Mode".to_string(),
|
title: Some("Select Approval Mode".to_string()),
|
||||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
items,
|
items,
|
||||||
is_searchable: true,
|
is_searchable: true,
|
||||||
@@ -579,6 +528,9 @@ mod tests {
|
|||||||
view.set_search_query("filters".to_string());
|
view.set_search_query("filters".to_string());
|
||||||
|
|
||||||
let lines = render_lines(&view);
|
let lines = render_lines(&view);
|
||||||
assert!(lines.contains("▌ filters"));
|
assert!(
|
||||||
|
lines.contains("filters"),
|
||||||
|
"expected search query line to include rendered query, got {lines:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,13 @@ use ratatui::layout::Rect;
|
|||||||
// Note: Table-based layout previously used Constraint; the manual renderer
|
// Note: Table-based layout previously used Constraint; the manual renderer
|
||||||
// below no longer requires it.
|
// below no longer requires it.
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use ratatui::style::Modifier;
|
|
||||||
use ratatui::style::Style;
|
|
||||||
use ratatui::style::Stylize;
|
use ratatui::style::Stylize;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::Paragraph;
|
|
||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
use unicode_width::UnicodeWidthChar;
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
use super::scroll_state::ScrollState;
|
use super::scroll_state::ScrollState;
|
||||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
|
||||||
|
|
||||||
/// A generic representation of a display row for selection popups.
|
/// A generic representation of a display row for selection popups.
|
||||||
pub(crate) struct GenericDisplayRow {
|
pub(crate) struct GenericDisplayRow {
|
||||||
@@ -23,8 +19,6 @@ pub(crate) struct GenericDisplayRow {
|
|||||||
pub description: Option<String>, // optional grey text after the name
|
pub description: Option<String>, // optional grey text after the name
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GenericDisplayRow {}
|
|
||||||
|
|
||||||
/// Compute a shared description-column start based on the widest visible name
|
/// Compute a shared description-column start based on the widest visible name
|
||||||
/// plus two spaces of padding. Ensures at least one column is left for the
|
/// plus two spaces of padding. Ensures at least one column is left for the
|
||||||
/// description.
|
/// description.
|
||||||
@@ -117,71 +111,19 @@ pub(crate) fn render_rows(
|
|||||||
state: &ScrollState,
|
state: &ScrollState,
|
||||||
max_results: usize,
|
max_results: usize,
|
||||||
empty_message: &str,
|
empty_message: &str,
|
||||||
include_border: bool,
|
|
||||||
) {
|
) {
|
||||||
if include_border {
|
|
||||||
use ratatui::widgets::Block;
|
|
||||||
use ratatui::widgets::BorderType;
|
|
||||||
use ratatui::widgets::Borders;
|
|
||||||
|
|
||||||
// Always draw a dim left border to match other popups.
|
|
||||||
let block = Block::default()
|
|
||||||
.borders(Borders::LEFT)
|
|
||||||
.border_type(BorderType::QuadrantOutside)
|
|
||||||
.border_style(Style::default().add_modifier(Modifier::DIM));
|
|
||||||
block.render(area, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content renders to the right of the border with the same live prefix
|
|
||||||
// padding used by the composer so the popup aligns with the input text.
|
|
||||||
let prefix_cols = LIVE_PREFIX_COLS;
|
|
||||||
let content_area = Rect {
|
|
||||||
x: area.x.saturating_add(prefix_cols),
|
|
||||||
y: area.y,
|
|
||||||
width: area.width.saturating_sub(prefix_cols),
|
|
||||||
height: area.height,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear the padding column(s) so stale characters never peek between the
|
|
||||||
// border and the popup contents.
|
|
||||||
let padding_cols = prefix_cols.saturating_sub(1);
|
|
||||||
if padding_cols > 0 {
|
|
||||||
let pad_start = area.x.saturating_add(1);
|
|
||||||
let pad_end = pad_start
|
|
||||||
.saturating_add(padding_cols)
|
|
||||||
.min(area.x.saturating_add(area.width));
|
|
||||||
let pad_bottom = area.y.saturating_add(area.height);
|
|
||||||
for x in pad_start..pad_end {
|
|
||||||
for y in area.y..pad_bottom {
|
|
||||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
|
||||||
cell.set_symbol(" ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows_all.is_empty() {
|
if rows_all.is_empty() {
|
||||||
if content_area.height > 0 {
|
if area.height > 0 {
|
||||||
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
|
Line::from(empty_message.dim().italic()).render(area, buf);
|
||||||
para.render(
|
|
||||||
Rect {
|
|
||||||
x: content_area.x,
|
|
||||||
y: content_area.y,
|
|
||||||
width: content_area.width,
|
|
||||||
height: 1,
|
|
||||||
},
|
|
||||||
buf,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which logical rows (items) are visible given the selection and
|
// Determine which logical rows (items) are visible given the selection and
|
||||||
// the max_results clamp. Scrolling is still item-based for simplicity.
|
// the max_results clamp. Scrolling is still item-based for simplicity.
|
||||||
let max_rows_from_area = content_area.height as usize;
|
|
||||||
let visible_items = max_results
|
let visible_items = max_results
|
||||||
.min(rows_all.len())
|
.min(rows_all.len())
|
||||||
.min(max_rows_from_area.max(1));
|
.min(area.height.max(1) as usize);
|
||||||
|
|
||||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||||
if let Some(sel) = state.selected_idx {
|
if let Some(sel) = state.selected_idx {
|
||||||
@@ -195,18 +137,18 @@ pub(crate) fn render_rows(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
|
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width);
|
||||||
|
|
||||||
// Render items, wrapping descriptions and aligning wrapped lines under the
|
// Render items, wrapping descriptions and aligning wrapped lines under the
|
||||||
// shared description column. Stop when we run out of vertical space.
|
// shared description column. Stop when we run out of vertical space.
|
||||||
let mut cur_y = content_area.y;
|
let mut cur_y = area.y;
|
||||||
for (i, row) in rows_all
|
for (i, row) in rows_all
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.skip(start_idx)
|
.skip(start_idx)
|
||||||
.take(visible_items)
|
.take(visible_items)
|
||||||
{
|
{
|
||||||
if cur_y >= content_area.y + content_area.height {
|
if cur_y >= area.y + area.height {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +159,7 @@ pub(crate) fn render_rows(
|
|||||||
description,
|
description,
|
||||||
} = row;
|
} = row;
|
||||||
|
|
||||||
let full_line = build_full_line(
|
let mut full_line = build_full_line(
|
||||||
&GenericDisplayRow {
|
&GenericDisplayRow {
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
match_indices: match_indices.clone(),
|
match_indices: match_indices.clone(),
|
||||||
@@ -226,32 +168,31 @@ pub(crate) fn render_rows(
|
|||||||
},
|
},
|
||||||
desc_col,
|
desc_col,
|
||||||
);
|
);
|
||||||
|
if Some(i) == state.selected_idx {
|
||||||
|
// Match previous behavior: cyan + bold for the selected row.
|
||||||
|
full_line.spans.iter_mut().for_each(|span| {
|
||||||
|
span.style = span.style.fg(Color::Cyan).bold();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap with subsequent indent aligned to the description column.
|
// Wrap with subsequent indent aligned to the description column.
|
||||||
use crate::wrapping::RtOptions;
|
use crate::wrapping::RtOptions;
|
||||||
use crate::wrapping::word_wrap_line;
|
use crate::wrapping::word_wrap_line;
|
||||||
let options = RtOptions::new(content_area.width as usize)
|
let options = RtOptions::new(area.width as usize)
|
||||||
.initial_indent(Line::from(""))
|
.initial_indent(Line::from(""))
|
||||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||||
let wrapped = word_wrap_line(&full_line, options);
|
let wrapped = word_wrap_line(&full_line, options);
|
||||||
|
|
||||||
// Render the wrapped lines.
|
// Render the wrapped lines.
|
||||||
for mut line in wrapped {
|
for line in wrapped {
|
||||||
if cur_y >= content_area.y + content_area.height {
|
if cur_y >= area.y + area.height {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if Some(i) == state.selected_idx {
|
line.render(
|
||||||
// Match previous behavior: cyan + bold for the selected row.
|
|
||||||
line.style = Style::default()
|
|
||||||
.fg(Color::Cyan)
|
|
||||||
.add_modifier(Modifier::BOLD);
|
|
||||||
}
|
|
||||||
let para = Paragraph::new(line);
|
|
||||||
para.render(
|
|
||||||
Rect {
|
Rect {
|
||||||
x: content_area.x,
|
x: area.x,
|
||||||
y: cur_y,
|
y: cur_y,
|
||||||
width: content_area.width,
|
width: area.width,
|
||||||
height: 1,
|
height: 1,
|
||||||
},
|
},
|
||||||
buf,
|
buf,
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
source: tui/src/bottom_pane/list_selection_view.rs
|
source: tui/src/bottom_pane/list_selection_view.rs
|
||||||
expression: render_lines(&view)
|
expression: render_lines(&view)
|
||||||
---
|
---
|
||||||
▌ Select Approval Mode
|
|
||||||
▌ Switch between Codex approval presets
|
|
||||||
▌
|
|
||||||
▌ > 1. Read Only (current) Codex can read files
|
|
||||||
▌ 2. Full Access Codex can edit files
|
|
||||||
|
|
||||||
Press Enter to confirm or Esc to go back
|
Select Approval Mode
|
||||||
|
Switch between Codex approval presets
|
||||||
|
|
||||||
|
› 1. Read Only (current) Codex can read files
|
||||||
|
2. Full Access Codex can edit files
|
||||||
|
|
||||||
|
Press Enter to confirm or Esc to go back
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
source: tui/src/bottom_pane/list_selection_view.rs
|
source: tui/src/bottom_pane/list_selection_view.rs
|
||||||
expression: render_lines(&view)
|
expression: render_lines(&view)
|
||||||
---
|
---
|
||||||
▌ Select Approval Mode
|
|
||||||
▌
|
|
||||||
▌ > 1. Read Only (current) Codex can read files
|
|
||||||
▌ 2. Full Access Codex can edit files
|
|
||||||
|
|
||||||
Press Enter to confirm or Esc to go back
|
Select Approval Mode
|
||||||
|
|
||||||
|
› 1. Read Only (current) Codex can read files
|
||||||
|
2. Full Access Codex can edit files
|
||||||
|
|
||||||
|
Press Enter to confirm or Esc to go back
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ use crate::history_cell;
|
|||||||
use crate::history_cell::AgentMessageCell;
|
use crate::history_cell::AgentMessageCell;
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
use crate::history_cell::McpToolCallCell;
|
use crate::history_cell::McpToolCallCell;
|
||||||
use crate::history_cell::PatchEventType;
|
|
||||||
use crate::markdown::append_markdown;
|
use crate::markdown::append_markdown;
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
use crate::status::RateLimitSnapshotDisplay;
|
use crate::status::RateLimitSnapshotDisplay;
|
||||||
@@ -534,9 +533,6 @@ impl ChatWidget {
|
|||||||
|
|
||||||
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
|
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
|
||||||
self.add_to_history(history_cell::new_patch_event(
|
self.add_to_history(history_cell::new_patch_event(
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: event.auto_approved,
|
|
||||||
},
|
|
||||||
event.changes,
|
event.changes,
|
||||||
&self.config.cwd,
|
&self.config.cwd,
|
||||||
));
|
));
|
||||||
@@ -736,8 +732,6 @@ impl ChatWidget {
|
|||||||
|
|
||||||
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
||||||
self.flush_answer_stream_with_separator();
|
self.flush_answer_stream_with_separator();
|
||||||
// Emit the proposed command into history (like proposed patches)
|
|
||||||
self.add_to_history(history_cell::new_proposed_command(&ev.command));
|
|
||||||
let command = shlex::try_join(ev.command.iter().map(String::as_str))
|
let command = shlex::try_join(ev.command.iter().map(String::as_str))
|
||||||
.unwrap_or_else(|_| ev.command.join(" "));
|
.unwrap_or_else(|_| ev.command.join(" "));
|
||||||
self.notify(Notification::ExecApprovalRequested { command });
|
self.notify(Notification::ExecApprovalRequested { command });
|
||||||
@@ -757,16 +751,12 @@ impl ChatWidget {
|
|||||||
ev: ApplyPatchApprovalRequestEvent,
|
ev: ApplyPatchApprovalRequestEvent,
|
||||||
) {
|
) {
|
||||||
self.flush_answer_stream_with_separator();
|
self.flush_answer_stream_with_separator();
|
||||||
self.add_to_history(history_cell::new_patch_event(
|
|
||||||
PatchEventType::ApprovalRequest,
|
|
||||||
ev.changes.clone(),
|
|
||||||
&self.config.cwd,
|
|
||||||
));
|
|
||||||
|
|
||||||
let request = ApprovalRequest::ApplyPatch {
|
let request = ApprovalRequest::ApplyPatch {
|
||||||
id,
|
id,
|
||||||
reason: ev.reason,
|
reason: ev.reason,
|
||||||
grant_root: ev.grant_root,
|
changes: ev.changes.clone(),
|
||||||
|
cwd: self.config.cwd.clone(),
|
||||||
};
|
};
|
||||||
self.bottom_pane.push_approval_request(request);
|
self.bottom_pane.push_approval_request(request);
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
@@ -1631,7 +1621,7 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
title: "Select model and reasoning level".to_string(),
|
title: Some("Select model and reasoning level".to_string()),
|
||||||
subtitle: Some(
|
subtitle: Some(
|
||||||
"Switch between OpenAI models for this and future Codex CLI session".to_string(),
|
"Switch between OpenAI models for this and future Codex CLI session".to_string(),
|
||||||
),
|
),
|
||||||
@@ -1677,7 +1667,7 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
title: "Select Approval Mode".to_string(),
|
title: Some("Select Approval Mode".to_string()),
|
||||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
items,
|
items,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1852,7 +1842,7 @@ impl ChatWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
title: "Select a review preset".into(),
|
title: Some("Select a review preset".into()),
|
||||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
items,
|
items,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1888,7 +1878,7 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
title: "Select a base branch".to_string(),
|
title: Some("Select a base branch".to_string()),
|
||||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
items,
|
items,
|
||||||
is_searchable: true,
|
is_searchable: true,
|
||||||
@@ -1929,7 +1919,7 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
title: "Select a commit to review".to_string(),
|
title: Some("Select a commit to review".to_string()),
|
||||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
items,
|
items,
|
||||||
is_searchable: true,
|
is_searchable: true,
|
||||||
@@ -2154,7 +2144,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
chat.bottom_pane.show_selection_view(SelectionViewParams {
|
chat.bottom_pane.show_selection_view(SelectionViewParams {
|
||||||
title: "Select a commit to review".to_string(),
|
title: Some("Select a commit to review".to_string()),
|
||||||
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
|
||||||
items,
|
items,
|
||||||
is_searchable: true,
|
is_searchable: true,
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
source: tui/src/chatwidget/tests.rs
|
source: tui/src/chatwidget/tests.rs
|
||||||
expression: lines_to_single_string(&approved_lines)
|
expression: lines_to_single_string(&approved_lines)
|
||||||
---
|
---
|
||||||
• Change Approved foo.txt (+1 -0)
|
• Added foo.txt (+1 -0)
|
||||||
|
1 +hello
|
||||||
|
|||||||
@@ -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
|
source: tui/src/chatwidget/tests.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend().vt100().screen().contents()
|
||||||
---
|
---
|
||||||
" "
|
Allow command?
|
||||||
"▌ this is a test reason such as one that would be produced by the model "
|
|
||||||
"▌ "
|
this is a test reason such as one that would be produced by the model
|
||||||
"▌ Command: echo hello world "
|
|
||||||
"▌ "
|
$ echo hello world
|
||||||
"▌ Allow command? "
|
|
||||||
"▌ "
|
› 1. Approve and run now Run this command one time
|
||||||
"▌ > 1. Approve and run now (Y) Run this command one time "
|
2. Always approve this session Automatically approve this command for the
|
||||||
"▌ 2. Always approve this session (A) Automatically approve this command for "
|
rest of the session
|
||||||
"▌ the rest of the session "
|
3. Cancel Do not run the command
|
||||||
"▌ 3. Cancel (N) Do not run the command "
|
|
||||||
" "
|
Press Enter to confirm or Esc to cancel
|
||||||
"Press Enter to confirm or Esc to cancel "
|
|
||||||
" "
|
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ source: tui/src/chatwidget/tests.rs
|
|||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
"▌ Command: echo hello world "
|
|
||||||
"▌ "
|
|
||||||
"▌ Allow command? "
|
|
||||||
"▌ "
|
|
||||||
"▌ > 1. Approve and run now (Y) Run this command one time "
|
|
||||||
"▌ 2. Always approve this session (A) Automatically approve this command for "
|
|
||||||
"▌ the rest of the session "
|
|
||||||
"▌ 3. Cancel (N) Do not run the command "
|
|
||||||
" "
|
" "
|
||||||
"Press Enter to confirm or Esc to cancel "
|
" Allow command? "
|
||||||
|
" "
|
||||||
|
" $ echo hello world "
|
||||||
|
" "
|
||||||
|
"› 1. Approve and run now Run this command one time "
|
||||||
|
" 2. Always approve this session Automatically approve this command for the "
|
||||||
|
" rest of the session "
|
||||||
|
" 3. Cancel Do not run the command "
|
||||||
|
" "
|
||||||
|
" Press Enter to confirm or Esc to cancel "
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ source: tui/src/chatwidget/tests.rs
|
|||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
" "
|
" "
|
||||||
"▌ The model wants to apply changes "
|
|
||||||
"▌ "
|
|
||||||
"▌ Grant write access to /tmp for the remainder of this session. "
|
|
||||||
"▌ "
|
|
||||||
"▌ Apply changes? "
|
|
||||||
"▌ "
|
|
||||||
"▌ > 1. Approve (Y) Apply the proposed changes "
|
|
||||||
"▌ 2. Cancel (N) Do not apply the changes "
|
|
||||||
" "
|
" "
|
||||||
"Press Enter to confirm or Esc to cancel "
|
" Apply changes? "
|
||||||
|
" "
|
||||||
|
" README.md (+2 -0) "
|
||||||
|
" 1 +hello "
|
||||||
|
" 2 +world "
|
||||||
|
" "
|
||||||
|
" The model wants to apply changes "
|
||||||
|
" "
|
||||||
|
"› 1. Approve Apply the proposed changes "
|
||||||
|
" 2. Cancel Do not apply the changes "
|
||||||
|
" "
|
||||||
|
" Press Enter to confirm or Esc to cancel "
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -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()
|
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
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
#[test]
|
|
||||||
fn final_answer_without_newline_is_flushed_immediately() {
|
|
||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
|
||||||
|
|
||||||
// Set up a VT100 test terminal to capture ANSI visual output
|
|
||||||
let width: u16 = 80;
|
|
||||||
// Increased height to keep the initial banner/help lines in view even if
|
|
||||||
// the session renders an extra header line or minor layout changes occur.
|
|
||||||
let height: u16 = 2500;
|
|
||||||
let viewport = Rect::new(0, height - 1, width, 1);
|
|
||||||
let backend = ratatui::backend::TestBackend::new(width, height);
|
|
||||||
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
|
|
||||||
.expect("failed to construct terminal");
|
|
||||||
terminal.set_viewport_area(viewport);
|
|
||||||
|
|
||||||
// Simulate a streaming answer without any newline characters.
|
|
||||||
chat.handle_codex_event(Event {
|
|
||||||
id: "sub-a".into(),
|
|
||||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
|
||||||
delta: "Hi! How can I help with codex-rs or anything else today?".into(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now simulate the final AgentMessage which should flush the pending line immediately.
|
|
||||||
chat.handle_codex_event(Event {
|
|
||||||
id: "sub-a".into(),
|
|
||||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
|
||||||
message: "Hi! How can I help with codex-rs or anything else today?".into(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drain history insertions and verify the final line is present.
|
|
||||||
let cells = drain_insert_history(&mut rx);
|
|
||||||
assert!(
|
|
||||||
cells.iter().any(|lines| {
|
|
||||||
let s = lines
|
|
||||||
.iter()
|
|
||||||
.flat_map(|l| l.spans.iter())
|
|
||||||
.map(|sp| sp.content.clone())
|
|
||||||
.collect::<String>();
|
|
||||||
s.contains("codex")
|
|
||||||
}),
|
|
||||||
"expected 'codex' header to be emitted",
|
|
||||||
);
|
|
||||||
let found_final = cells.iter().any(|lines| {
|
|
||||||
let s = lines
|
|
||||||
.iter()
|
|
||||||
.flat_map(|l| l.spans.iter())
|
|
||||||
.map(|sp| sp.content.clone())
|
|
||||||
.collect::<String>();
|
|
||||||
s.contains("Hi! How can I help with codex-rs or anything else today?")
|
|
||||||
});
|
|
||||||
assert!(
|
|
||||||
found_final,
|
|
||||||
"expected final answer text to be flushed to history"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resumed_initial_messages_render_history() {
|
fn resumed_initial_messages_render_history() {
|
||||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
|
||||||
@@ -452,15 +392,18 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
|
|||||||
msg: EventMsg::ExecApprovalRequest(ev),
|
msg: EventMsg::ExecApprovalRequest(ev),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Snapshot the Proposed Command cell emitted into history
|
let proposed_cells = drain_insert_history(&mut rx);
|
||||||
let proposed = drain_insert_history(&mut rx)
|
assert!(
|
||||||
.pop()
|
proposed_cells.is_empty(),
|
||||||
.expect("expected proposed command cell");
|
"expected approval request to render via modal without emitting history cells"
|
||||||
assert_snapshot!(
|
|
||||||
"exec_approval_history_proposed_short",
|
|
||||||
lines_to_single_string(&proposed)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The approval modal should display the command snippet for user confirmation.
|
||||||
|
let area = Rect::new(0, 0, 80, chat.desired_height(80));
|
||||||
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||||
|
(&chat).render_ref(area, &mut buf);
|
||||||
|
assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}"));
|
||||||
|
|
||||||
// Approve via keyboard and verify a concise decision history line is added
|
// Approve via keyboard and verify a concise decision history line is added
|
||||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||||
let decision = drain_insert_history(&mut rx)
|
let decision = drain_insert_history(&mut rx)
|
||||||
@@ -476,7 +419,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
|
|||||||
fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
||||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||||
|
|
||||||
// Multiline command: should render proposed command fully in history with prefixes
|
// Multiline command: modal should show full command, history records decision only
|
||||||
let ev_multi = ExecApprovalRequestEvent {
|
let ev_multi = ExecApprovalRequestEvent {
|
||||||
call_id: "call-multi".into(),
|
call_id: "call-multi".into(),
|
||||||
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
|
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
|
||||||
@@ -489,12 +432,29 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
|||||||
id: "sub-multi".into(),
|
id: "sub-multi".into(),
|
||||||
msg: EventMsg::ExecApprovalRequest(ev_multi),
|
msg: EventMsg::ExecApprovalRequest(ev_multi),
|
||||||
});
|
});
|
||||||
let proposed_multi = drain_insert_history(&mut rx)
|
let proposed_multi = drain_insert_history(&mut rx);
|
||||||
.pop()
|
assert!(
|
||||||
.expect("expected proposed multiline command cell");
|
proposed_multi.is_empty(),
|
||||||
assert_snapshot!(
|
"expected multiline approval request to render via modal without emitting history cells"
|
||||||
"exec_approval_history_proposed_multiline",
|
);
|
||||||
lines_to_single_string(&proposed_multi)
|
|
||||||
|
let area = Rect::new(0, 0, 80, chat.desired_height(80));
|
||||||
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||||
|
(&chat).render_ref(area, &mut buf);
|
||||||
|
let mut saw_first_line = false;
|
||||||
|
for y in 0..area.height {
|
||||||
|
let mut row = String::new();
|
||||||
|
for x in 0..area.width {
|
||||||
|
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||||
|
}
|
||||||
|
if row.contains("echo line1") {
|
||||||
|
saw_first_line = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
saw_first_line,
|
||||||
|
"expected modal to show first line of multiline snippet"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Deny via keyboard; decision snippet should be single-line and elided with " ..."
|
// Deny via keyboard; decision snippet should be single-line and elided with " ..."
|
||||||
@@ -519,7 +479,11 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
|
|||||||
id: "sub-long".into(),
|
id: "sub-long".into(),
|
||||||
msg: EventMsg::ExecApprovalRequest(ev_long),
|
msg: EventMsg::ExecApprovalRequest(ev_long),
|
||||||
});
|
});
|
||||||
drain_insert_history(&mut rx); // proposed cell not needed for this assertion
|
let proposed_long = drain_insert_history(&mut rx);
|
||||||
|
assert!(
|
||||||
|
proposed_long.is_empty(),
|
||||||
|
"expected long approval request to avoid emitting history cells before decision"
|
||||||
|
);
|
||||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
|
||||||
let aborted_long = drain_insert_history(&mut rx)
|
let aborted_long = drain_insert_history(&mut rx)
|
||||||
.pop()
|
.pop()
|
||||||
@@ -935,18 +899,21 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
|
|||||||
let area = Rect::new(0, 0, width, height);
|
let area = Rect::new(0, 0, width, height);
|
||||||
let mut buf = Buffer::empty(area);
|
let mut buf = Buffer::empty(area);
|
||||||
(chat).render_ref(area, &mut buf);
|
(chat).render_ref(area, &mut buf);
|
||||||
let mut row = String::new();
|
for y in 0..area.height {
|
||||||
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
|
let mut row = String::new();
|
||||||
let y = 1u16.min(height.saturating_sub(1));
|
for x in 0..area.width {
|
||||||
for x in 0..area.width {
|
let s = buf[(x, y)].symbol();
|
||||||
let s = buf[(x, y)].symbol();
|
if s.is_empty() {
|
||||||
if s.is_empty() {
|
row.push(' ');
|
||||||
row.push(' ');
|
} else {
|
||||||
} else {
|
row.push_str(s);
|
||||||
row.push_str(s);
|
}
|
||||||
|
}
|
||||||
|
if !row.trim().is_empty() {
|
||||||
|
return row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row
|
String::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1181,12 +1148,19 @@ fn approval_modal_exec_snapshot() {
|
|||||||
// Render to a fixed-size test terminal and snapshot.
|
// Render to a fixed-size test terminal and snapshot.
|
||||||
// Call desired_height first and use that exact height for rendering.
|
// Call desired_height first and use that exact height for rendering.
|
||||||
let height = chat.desired_height(80);
|
let height = chat.desired_height(80);
|
||||||
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
|
let mut terminal =
|
||||||
.expect("create terminal");
|
crate::custom_terminal::Terminal::with_options(VT100Backend::new(80, height))
|
||||||
|
.expect("create terminal");
|
||||||
|
let viewport = Rect::new(0, 0, 80, height);
|
||||||
|
terminal.set_viewport_area(viewport);
|
||||||
|
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
.draw(|f| f.render_widget_ref(&chat, f.area()))
|
||||||
.expect("draw approval modal");
|
.expect("draw approval modal");
|
||||||
assert_snapshot!("approval_modal_exec", terminal.backend());
|
assert_snapshot!(
|
||||||
|
"approval_modal_exec",
|
||||||
|
terminal.backend().vt100().screen().contents()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot test: command approval modal without a reason
|
// Snapshot test: command approval modal without a reason
|
||||||
@@ -1470,13 +1444,27 @@ fn apply_patch_events_emit_history_cells() {
|
|||||||
msg: EventMsg::ApplyPatchApprovalRequest(ev),
|
msg: EventMsg::ApplyPatchApprovalRequest(ev),
|
||||||
});
|
});
|
||||||
let cells = drain_insert_history(&mut rx);
|
let cells = drain_insert_history(&mut rx);
|
||||||
assert!(!cells.is_empty(), "expected pending patch cell to be sent");
|
|
||||||
let blob = lines_to_single_string(cells.last().unwrap());
|
|
||||||
assert!(
|
assert!(
|
||||||
blob.contains("Proposed Change"),
|
cells.is_empty(),
|
||||||
"missing proposed change header: {blob:?}"
|
"expected approval request to surface via modal without emitting history cells"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let area = Rect::new(0, 0, 80, chat.desired_height(80));
|
||||||
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||||
|
(&chat).render_ref(area, &mut buf);
|
||||||
|
let mut saw_summary = false;
|
||||||
|
for y in 0..area.height {
|
||||||
|
let mut row = String::new();
|
||||||
|
for x in 0..area.width {
|
||||||
|
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||||
|
}
|
||||||
|
if row.contains("foo.txt (+1 -0)") {
|
||||||
|
saw_summary = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_summary, "expected approval modal to show diff summary");
|
||||||
|
|
||||||
// 2) Begin apply -> per-file apply block cell (no global header)
|
// 2) Begin apply -> per-file apply block cell (no global header)
|
||||||
let mut changes2 = HashMap::new();
|
let mut changes2 = HashMap::new();
|
||||||
changes2.insert(
|
changes2.insert(
|
||||||
@@ -1562,8 +1550,8 @@ fn apply_patch_manual_approval_adjusts_header() {
|
|||||||
assert!(!cells.is_empty(), "expected apply block cell to be sent");
|
assert!(!cells.is_empty(), "expected apply block cell to be sent");
|
||||||
let blob = lines_to_single_string(cells.last().unwrap());
|
let blob = lines_to_single_string(cells.last().unwrap());
|
||||||
assert!(
|
assert!(
|
||||||
blob.contains("Change Approved foo.txt"),
|
blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"),
|
||||||
"expected change approved summary: {blob:?}"
|
"expected apply summary header for foo.txt: {blob:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1587,9 +1575,11 @@ fn apply_patch_manual_flow_snapshot() {
|
|||||||
grant_root: None,
|
grant_root: None,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
let proposed_lines = drain_insert_history(&mut rx)
|
let history_before_apply = drain_insert_history(&mut rx);
|
||||||
.pop()
|
assert!(
|
||||||
.expect("proposed patch cell");
|
history_before_apply.is_empty(),
|
||||||
|
"expected approval modal to defer history emission"
|
||||||
|
);
|
||||||
|
|
||||||
let mut apply_changes = HashMap::new();
|
let mut apply_changes = HashMap::new();
|
||||||
apply_changes.insert(
|
apply_changes.insert(
|
||||||
@@ -1610,10 +1600,6 @@ fn apply_patch_manual_flow_snapshot() {
|
|||||||
.pop()
|
.pop()
|
||||||
.expect("approved patch cell");
|
.expect("approved patch cell");
|
||||||
|
|
||||||
assert_snapshot!(
|
|
||||||
"apply_patch_manual_flow_history_proposed",
|
|
||||||
lines_to_single_string(&proposed_lines)
|
|
||||||
);
|
|
||||||
assert_snapshot!(
|
assert_snapshot!(
|
||||||
"apply_patch_manual_flow_history_approved",
|
"apply_patch_manual_flow_history_approved",
|
||||||
lines_to_single_string(&approved_lines)
|
lines_to_single_string(&approved_lines)
|
||||||
@@ -1803,24 +1789,42 @@ fn apply_patch_request_shows_diff_summary() {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drain history insertions and verify the diff summary is present
|
// No history entries yet; the modal should contain the diff summary
|
||||||
let cells = drain_insert_history(&mut rx);
|
let cells = drain_insert_history(&mut rx);
|
||||||
assert!(
|
assert!(
|
||||||
!cells.is_empty(),
|
cells.is_empty(),
|
||||||
"expected a history cell with the proposed patch summary"
|
"expected approval request to render via modal instead of history"
|
||||||
);
|
|
||||||
let blob = lines_to_single_string(cells.last().unwrap());
|
|
||||||
|
|
||||||
// Header should summarize totals
|
|
||||||
assert!(
|
|
||||||
blob.contains("Proposed Change README.md (+2 -0)"),
|
|
||||||
"missing or incorrect diff header: {blob:?}"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Per-file summary line should include the file path and counts
|
let area = Rect::new(0, 0, 80, chat.desired_height(80));
|
||||||
|
let mut buf = ratatui::buffer::Buffer::empty(area);
|
||||||
|
(&chat).render_ref(area, &mut buf);
|
||||||
|
|
||||||
|
let mut saw_header = false;
|
||||||
|
let mut saw_line1 = false;
|
||||||
|
let mut saw_line2 = false;
|
||||||
|
for y in 0..area.height {
|
||||||
|
let mut row = String::new();
|
||||||
|
for x in 0..area.width {
|
||||||
|
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||||
|
}
|
||||||
|
if row.contains("README.md (+2 -0)") {
|
||||||
|
saw_header = true;
|
||||||
|
}
|
||||||
|
if row.contains("+line one") {
|
||||||
|
saw_line1 = true;
|
||||||
|
}
|
||||||
|
if row.contains("+line two") {
|
||||||
|
saw_line2 = true;
|
||||||
|
}
|
||||||
|
if saw_header && saw_line1 && saw_line2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_header, "expected modal to show diff header with totals");
|
||||||
assert!(
|
assert!(
|
||||||
blob.contains("README.md"),
|
saw_line1 && saw_line2,
|
||||||
"missing per-file diff summary: {blob:?}"
|
"expected modal to show per-line diff summary"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
use diffy::Hunk;
|
use diffy::Hunk;
|
||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use ratatui::style::Modifier;
|
use ratatui::style::Modifier;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
use ratatui::style::Stylize;
|
use ratatui::style::Stylize;
|
||||||
use ratatui::text::Line as RtLine;
|
use ratatui::text::Line as RtLine;
|
||||||
use ratatui::text::Span as RtSpan;
|
use ratatui::text::Span as RtSpan;
|
||||||
|
use ratatui::widgets::Paragraph;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::exec_command::relativize_to_home;
|
use crate::exec_command::relativize_to_home;
|
||||||
use crate::history_cell::PatchEventType;
|
use crate::render::renderable::ColumnRenderable;
|
||||||
|
use crate::render::renderable::Renderable;
|
||||||
use codex_core::git_info::get_git_repo_root;
|
use codex_core::git_info::get_git_repo_root;
|
||||||
use codex_core::protocol::FileChange;
|
use codex_core::protocol::FileChange;
|
||||||
|
|
||||||
@@ -23,24 +27,57 @@ enum DiffLineType {
|
|||||||
Context,
|
Context,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct DiffSummary {
|
||||||
|
changes: HashMap<PathBuf, FileChange>,
|
||||||
|
cwd: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiffSummary {
|
||||||
|
pub fn new(changes: HashMap<PathBuf, FileChange>, cwd: PathBuf) -> Self {
|
||||||
|
Self { changes, cwd }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for FileChange {
|
||||||
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let mut lines = vec![];
|
||||||
|
render_change(self, &mut lines, area.width as usize);
|
||||||
|
Paragraph::new(lines).render(area, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
|
let mut lines = vec![];
|
||||||
|
render_change(self, &mut lines, width as usize);
|
||||||
|
lines.len() as u16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DiffSummary> for Box<dyn Renderable> {
|
||||||
|
fn from(val: DiffSummary) -> Self {
|
||||||
|
let mut rows: Vec<Box<dyn Renderable>> = vec![];
|
||||||
|
|
||||||
|
for (i, row) in collect_rows(&val.changes).into_iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
rows.push(Box::new(RtLine::from("")));
|
||||||
|
}
|
||||||
|
let mut path = RtLine::from(display_path_for(&row.path, &val.cwd));
|
||||||
|
path.push_span(" ");
|
||||||
|
path.extend(render_line_count_summary(row.added, row.removed));
|
||||||
|
rows.push(Box::new(path));
|
||||||
|
rows.push(Box::new(row.change));
|
||||||
|
}
|
||||||
|
|
||||||
|
Box::new(ColumnRenderable::new(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn create_diff_summary(
|
pub(crate) fn create_diff_summary(
|
||||||
changes: &HashMap<PathBuf, FileChange>,
|
changes: &HashMap<PathBuf, FileChange>,
|
||||||
event_type: PatchEventType,
|
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
wrap_cols: usize,
|
wrap_cols: usize,
|
||||||
) -> Vec<RtLine<'static>> {
|
) -> Vec<RtLine<'static>> {
|
||||||
let rows = collect_rows(changes);
|
let rows = collect_rows(changes);
|
||||||
let header_kind = match event_type {
|
render_changes_block(rows, wrap_cols, cwd)
|
||||||
PatchEventType::ApplyBegin { auto_approved } => {
|
|
||||||
if auto_approved {
|
|
||||||
HeaderKind::Edited
|
|
||||||
} else {
|
|
||||||
HeaderKind::ChangeApproved
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PatchEventType::ApprovalRequest => HeaderKind::ProposedChange,
|
|
||||||
};
|
|
||||||
render_changes_block(rows, wrap_cols, header_kind, cwd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared row for per-file presentation
|
// Shared row for per-file presentation
|
||||||
@@ -81,30 +118,18 @@ fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
|
|||||||
rows
|
rows
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HeaderKind {
|
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
|
||||||
ProposedChange,
|
let mut spans = Vec::new();
|
||||||
Edited,
|
spans.push("(".into());
|
||||||
ChangeApproved,
|
spans.push(format!("+{added}").green());
|
||||||
|
spans.push(" ".into());
|
||||||
|
spans.push(format!("-{removed}").red());
|
||||||
|
spans.push(")".into());
|
||||||
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_changes_block(
|
fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtLine<'static>> {
|
||||||
rows: Vec<Row>,
|
|
||||||
wrap_cols: usize,
|
|
||||||
header_kind: HeaderKind,
|
|
||||||
cwd: &Path,
|
|
||||||
) -> Vec<RtLine<'static>> {
|
|
||||||
let mut out: Vec<RtLine<'static>> = Vec::new();
|
let mut out: Vec<RtLine<'static>> = Vec::new();
|
||||||
let term_cols = wrap_cols;
|
|
||||||
|
|
||||||
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
|
|
||||||
let mut spans = Vec::new();
|
|
||||||
spans.push("(".into());
|
|
||||||
spans.push(format!("+{added}").green());
|
|
||||||
spans.push(" ".into());
|
|
||||||
spans.push(format!("-{removed}").red());
|
|
||||||
spans.push(")".into());
|
|
||||||
spans
|
|
||||||
}
|
|
||||||
|
|
||||||
let render_path = |row: &Row| -> Vec<RtSpan<'static>> {
|
let render_path = |row: &Row| -> Vec<RtSpan<'static>> {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
@@ -121,66 +146,31 @@ fn render_changes_block(
|
|||||||
let file_count = rows.len();
|
let file_count = rows.len();
|
||||||
let noun = if file_count == 1 { "file" } else { "files" };
|
let noun = if file_count == 1 { "file" } else { "files" };
|
||||||
let mut header_spans: Vec<RtSpan<'static>> = vec!["• ".into()];
|
let mut header_spans: Vec<RtSpan<'static>> = vec!["• ".into()];
|
||||||
match header_kind {
|
if let [row] = &rows[..] {
|
||||||
HeaderKind::ProposedChange => {
|
let verb = match &row.change {
|
||||||
header_spans.push("Proposed Change".bold());
|
FileChange::Add { .. } => "Added",
|
||||||
if let [row] = &rows[..] {
|
FileChange::Delete { .. } => "Deleted",
|
||||||
header_spans.push(" ".into());
|
_ => "Edited",
|
||||||
header_spans.extend(render_path(row));
|
};
|
||||||
header_spans.push(" ".into());
|
header_spans.push(verb.bold());
|
||||||
header_spans.extend(render_line_count_summary(row.added, row.removed));
|
header_spans.push(" ".into());
|
||||||
} else {
|
header_spans.extend(render_path(row));
|
||||||
header_spans.push(format!(" to {file_count} {noun} ").into());
|
header_spans.push(" ".into());
|
||||||
header_spans.extend(render_line_count_summary(total_added, total_removed));
|
header_spans.extend(render_line_count_summary(row.added, row.removed));
|
||||||
}
|
} else {
|
||||||
}
|
header_spans.push("Edited".bold());
|
||||||
HeaderKind::Edited => {
|
header_spans.push(format!(" {file_count} {noun} ").into());
|
||||||
if let [row] = &rows[..] {
|
header_spans.extend(render_line_count_summary(total_added, total_removed));
|
||||||
let verb = match &row.change {
|
|
||||||
FileChange::Add { .. } => "Added",
|
|
||||||
FileChange::Delete { .. } => "Deleted",
|
|
||||||
_ => "Edited",
|
|
||||||
};
|
|
||||||
header_spans.push(verb.bold());
|
|
||||||
header_spans.push(" ".into());
|
|
||||||
header_spans.extend(render_path(row));
|
|
||||||
header_spans.push(" ".into());
|
|
||||||
header_spans.extend(render_line_count_summary(row.added, row.removed));
|
|
||||||
} else {
|
|
||||||
header_spans.push("Edited".bold());
|
|
||||||
header_spans.push(format!(" {file_count} {noun} ").into());
|
|
||||||
header_spans.extend(render_line_count_summary(total_added, total_removed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HeaderKind::ChangeApproved => {
|
|
||||||
header_spans.push("Change Approved".bold());
|
|
||||||
if let [row] = &rows[..] {
|
|
||||||
header_spans.push(" ".into());
|
|
||||||
header_spans.extend(render_path(row));
|
|
||||||
header_spans.push(" ".into());
|
|
||||||
header_spans.extend(render_line_count_summary(row.added, row.removed));
|
|
||||||
} else {
|
|
||||||
header_spans.push(format!(" {file_count} {noun} ").into());
|
|
||||||
header_spans.extend(render_line_count_summary(total_added, total_removed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out.push(RtLine::from(header_spans));
|
out.push(RtLine::from(header_spans));
|
||||||
|
|
||||||
// For Change Approved, we only show the header summary and no per-file/diff details.
|
|
||||||
if matches!(header_kind, HeaderKind::ChangeApproved) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (idx, r) in rows.into_iter().enumerate() {
|
for (idx, r) in rows.into_iter().enumerate() {
|
||||||
// Insert a blank separator between file chunks (except before the first)
|
// Insert a blank separator between file chunks (except before the first)
|
||||||
if idx > 0 {
|
if idx > 0 {
|
||||||
out.push("".into());
|
out.push("".into());
|
||||||
}
|
}
|
||||||
// File header line (skip when single-file header already shows the name)
|
// File header line (skip when single-file header already shows the name)
|
||||||
let skip_file_header =
|
let skip_file_header = file_count == 1;
|
||||||
matches!(header_kind, HeaderKind::ProposedChange | HeaderKind::Edited)
|
|
||||||
&& file_count == 1;
|
|
||||||
if !skip_file_header {
|
if !skip_file_header {
|
||||||
let mut header: Vec<RtSpan<'static>> = Vec::new();
|
let mut header: Vec<RtSpan<'static>> = Vec::new();
|
||||||
header.push(" └ ".dim());
|
header.push(" └ ".dim());
|
||||||
@@ -190,71 +180,77 @@ fn render_changes_block(
|
|||||||
out.push(RtLine::from(header));
|
out.push(RtLine::from(header));
|
||||||
}
|
}
|
||||||
|
|
||||||
match r.change {
|
render_change(&r.change, &mut out, wrap_cols);
|
||||||
FileChange::Add { content } => {
|
}
|
||||||
for (i, raw) in content.lines().enumerate() {
|
|
||||||
out.extend(push_wrapped_diff_line(
|
|
||||||
i + 1,
|
|
||||||
DiffLineType::Insert,
|
|
||||||
raw,
|
|
||||||
term_cols,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FileChange::Delete { content } => {
|
|
||||||
for (i, raw) in content.lines().enumerate() {
|
|
||||||
out.extend(push_wrapped_diff_line(
|
|
||||||
i + 1,
|
|
||||||
DiffLineType::Delete,
|
|
||||||
raw,
|
|
||||||
term_cols,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FileChange::Update { unified_diff, .. } => {
|
|
||||||
if let Ok(patch) = diffy::Patch::from_str(&unified_diff) {
|
|
||||||
let mut is_first_hunk = true;
|
|
||||||
for h in patch.hunks() {
|
|
||||||
if !is_first_hunk {
|
|
||||||
out.push(RtLine::from(vec![" ".into(), "⋮".dim()]));
|
|
||||||
}
|
|
||||||
is_first_hunk = false;
|
|
||||||
|
|
||||||
let mut old_ln = h.old_range().start();
|
out
|
||||||
let mut new_ln = h.new_range().start();
|
}
|
||||||
for l in h.lines() {
|
|
||||||
match l {
|
fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usize) {
|
||||||
diffy::Line::Insert(text) => {
|
match change {
|
||||||
let s = text.trim_end_matches('\n');
|
FileChange::Add { content } => {
|
||||||
out.extend(push_wrapped_diff_line(
|
for (i, raw) in content.lines().enumerate() {
|
||||||
new_ln,
|
out.extend(push_wrapped_diff_line(
|
||||||
DiffLineType::Insert,
|
i + 1,
|
||||||
s,
|
DiffLineType::Insert,
|
||||||
term_cols,
|
raw,
|
||||||
));
|
width,
|
||||||
new_ln += 1;
|
));
|
||||||
}
|
}
|
||||||
diffy::Line::Delete(text) => {
|
}
|
||||||
let s = text.trim_end_matches('\n');
|
FileChange::Delete { content } => {
|
||||||
out.extend(push_wrapped_diff_line(
|
for (i, raw) in content.lines().enumerate() {
|
||||||
old_ln,
|
out.extend(push_wrapped_diff_line(
|
||||||
DiffLineType::Delete,
|
i + 1,
|
||||||
s,
|
DiffLineType::Delete,
|
||||||
term_cols,
|
raw,
|
||||||
));
|
width,
|
||||||
old_ln += 1;
|
));
|
||||||
}
|
}
|
||||||
diffy::Line::Context(text) => {
|
}
|
||||||
let s = text.trim_end_matches('\n');
|
FileChange::Update { unified_diff, .. } => {
|
||||||
out.extend(push_wrapped_diff_line(
|
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
|
||||||
new_ln,
|
let mut is_first_hunk = true;
|
||||||
DiffLineType::Context,
|
for h in patch.hunks() {
|
||||||
s,
|
if !is_first_hunk {
|
||||||
term_cols,
|
out.push(RtLine::from(vec![" ".into(), "⋮".dim()]));
|
||||||
));
|
}
|
||||||
old_ln += 1;
|
is_first_hunk = false;
|
||||||
new_ln += 1;
|
|
||||||
}
|
let mut old_ln = h.old_range().start();
|
||||||
|
let mut new_ln = h.new_range().start();
|
||||||
|
for l in h.lines() {
|
||||||
|
match l {
|
||||||
|
diffy::Line::Insert(text) => {
|
||||||
|
let s = text.trim_end_matches('\n');
|
||||||
|
out.extend(push_wrapped_diff_line(
|
||||||
|
new_ln,
|
||||||
|
DiffLineType::Insert,
|
||||||
|
s,
|
||||||
|
width,
|
||||||
|
));
|
||||||
|
new_ln += 1;
|
||||||
|
}
|
||||||
|
diffy::Line::Delete(text) => {
|
||||||
|
let s = text.trim_end_matches('\n');
|
||||||
|
out.extend(push_wrapped_diff_line(
|
||||||
|
old_ln,
|
||||||
|
DiffLineType::Delete,
|
||||||
|
s,
|
||||||
|
width,
|
||||||
|
));
|
||||||
|
old_ln += 1;
|
||||||
|
}
|
||||||
|
diffy::Line::Context(text) => {
|
||||||
|
let s = text.trim_end_matches('\n');
|
||||||
|
out.extend(push_wrapped_diff_line(
|
||||||
|
new_ln,
|
||||||
|
DiffLineType::Context,
|
||||||
|
s,
|
||||||
|
width,
|
||||||
|
));
|
||||||
|
old_ln += 1;
|
||||||
|
new_ln += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,8 +258,6 @@ fn render_changes_block(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
|
pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
|
||||||
@@ -300,7 +294,7 @@ fn push_wrapped_diff_line(
|
|||||||
line_number: usize,
|
line_number: usize,
|
||||||
kind: DiffLineType,
|
kind: DiffLineType,
|
||||||
text: &str,
|
text: &str,
|
||||||
term_cols: usize,
|
width: usize,
|
||||||
) -> Vec<RtLine<'static>> {
|
) -> Vec<RtLine<'static>> {
|
||||||
let indent = " ";
|
let indent = " ";
|
||||||
let ln_str = line_number.to_string();
|
let ln_str = line_number.to_string();
|
||||||
@@ -325,7 +319,7 @@ fn push_wrapped_diff_line(
|
|||||||
// Fit the content for the current terminal row:
|
// Fit the content for the current terminal row:
|
||||||
// compute how many columns are available after the prefix, then split
|
// compute how many columns are available after the prefix, then split
|
||||||
// at a UTF-8 character boundary so this row's chunk fits exactly.
|
// at a UTF-8 character boundary so this row's chunk fits exactly.
|
||||||
let available_content_cols = term_cols.saturating_sub(prefix_cols + 1).max(1);
|
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
|
||||||
let split_at_byte_index = remaining_text
|
let split_at_byte_index = remaining_text
|
||||||
.char_indices()
|
.char_indices()
|
||||||
.nth(available_content_cols)
|
.nth(available_content_cols)
|
||||||
@@ -385,11 +379,8 @@ mod tests {
|
|||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
use ratatui::widgets::Wrap;
|
use ratatui::widgets::Wrap;
|
||||||
fn diff_summary_for_tests(
|
fn diff_summary_for_tests(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
|
||||||
changes: &HashMap<PathBuf, FileChange>,
|
create_diff_summary(changes, &PathBuf::from("/"), 80)
|
||||||
event_type: PatchEventType,
|
|
||||||
) -> Vec<RtLine<'static>> {
|
|
||||||
create_diff_summary(changes, event_type, &PathBuf::from("/"), 80)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
|
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
|
||||||
@@ -421,42 +412,6 @@ mod tests {
|
|||||||
assert_snapshot!(name, text);
|
assert_snapshot!(name, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ui_snapshot_add_details() {
|
|
||||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
|
||||||
changes.insert(
|
|
||||||
PathBuf::from("README.md"),
|
|
||||||
FileChange::Add {
|
|
||||||
content: "first line\nsecond line\n".to_string(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
snapshot_lines("add_details", lines, 80, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ui_snapshot_update_details_with_rename() {
|
|
||||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
|
||||||
|
|
||||||
let original = "line one\nline two\nline three\n";
|
|
||||||
let modified = "line one\nline two changed\nline three\n";
|
|
||||||
let patch = diffy::create_patch(original, modified).to_string();
|
|
||||||
|
|
||||||
changes.insert(
|
|
||||||
PathBuf::from("src/lib.rs"),
|
|
||||||
FileChange::Update {
|
|
||||||
unified_diff: patch,
|
|
||||||
move_path: Some(PathBuf::from("src/lib_new.rs")),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
snapshot_lines("update_details_with_rename", lines, 80, 12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ui_snapshot_wrap_behavior_insert() {
|
fn ui_snapshot_wrap_behavior_insert() {
|
||||||
// Narrow width to force wrapping within our diff line rendering
|
// Narrow width to force wrapping within our diff line rendering
|
||||||
@@ -469,71 +424,6 @@ mod tests {
|
|||||||
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
|
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ui_snapshot_single_line_replacement_counts() {
|
|
||||||
// Reproduce: one deleted line replaced by one inserted line, no extra context
|
|
||||||
let original = "# Codex CLI (Rust Implementation)\n";
|
|
||||||
let modified = "# Codex CLI (Rust Implementation) banana\n";
|
|
||||||
let patch = diffy::create_patch(original, modified).to_string();
|
|
||||||
|
|
||||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
|
||||||
changes.insert(
|
|
||||||
PathBuf::from("README.md"),
|
|
||||||
FileChange::Update {
|
|
||||||
unified_diff: patch,
|
|
||||||
move_path: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
snapshot_lines("single_line_replacement_counts", lines, 80, 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ui_snapshot_blank_context_line() {
|
|
||||||
// Ensure a hunk that includes a blank context line at the beginning is rendered visibly
|
|
||||||
let original = "\nY\n";
|
|
||||||
let modified = "\nY changed\n";
|
|
||||||
let patch = diffy::create_patch(original, modified).to_string();
|
|
||||||
|
|
||||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
|
||||||
changes.insert(
|
|
||||||
PathBuf::from("example.txt"),
|
|
||||||
FileChange::Update {
|
|
||||||
unified_diff: patch,
|
|
||||||
move_path: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
snapshot_lines("blank_context_line", lines, 80, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ui_snapshot_vertical_ellipsis_between_hunks() {
|
|
||||||
// Create a patch with two separate hunks to ensure we render the vertical ellipsis (⋮)
|
|
||||||
let original =
|
|
||||||
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n";
|
|
||||||
let modified = "line 1\nline two changed\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline nine changed\nline 10\n";
|
|
||||||
let patch = diffy::create_patch(original, modified).to_string();
|
|
||||||
|
|
||||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
|
||||||
changes.insert(
|
|
||||||
PathBuf::from("example.txt"),
|
|
||||||
FileChange::Update {
|
|
||||||
unified_diff: patch,
|
|
||||||
move_path: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
|
|
||||||
|
|
||||||
// Height is large enough to show both hunks and the separator
|
|
||||||
snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ui_snapshot_apply_update_block() {
|
fn ui_snapshot_apply_update_block() {
|
||||||
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
|
||||||
@@ -549,12 +439,8 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
for (name, auto_approved) in [
|
for name in ["apply_update_block", "apply_update_block_manual"] {
|
||||||
("apply_update_block", true),
|
let lines = diff_summary_for_tests(&changes);
|
||||||
("apply_update_block_manual", false),
|
|
||||||
] {
|
|
||||||
let lines =
|
|
||||||
diff_summary_for_tests(&changes, PatchEventType::ApplyBegin { auto_approved });
|
|
||||||
|
|
||||||
snapshot_lines(name, lines, 80, 12);
|
snapshot_lines(name, lines, 80, 12);
|
||||||
}
|
}
|
||||||
@@ -575,12 +461,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(
|
let lines = diff_summary_for_tests(&changes);
|
||||||
&changes,
|
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
|
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
|
||||||
}
|
}
|
||||||
@@ -608,12 +489,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(
|
let lines = diff_summary_for_tests(&changes);
|
||||||
&changes,
|
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
|
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
|
||||||
}
|
}
|
||||||
@@ -628,12 +504,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(
|
let lines = diff_summary_for_tests(&changes);
|
||||||
&changes,
|
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
snapshot_lines("apply_add_block", lines, 80, 10);
|
snapshot_lines("apply_add_block", lines, 80, 10);
|
||||||
}
|
}
|
||||||
@@ -652,12 +523,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines = diff_summary_for_tests(
|
let lines = diff_summary_for_tests(&changes);
|
||||||
&changes,
|
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup best-effort; rendering has already read the file
|
// Cleanup best-effort; rendering has already read the file
|
||||||
let _ = std::fs::remove_file(&tmp_path);
|
let _ = std::fs::remove_file(&tmp_path);
|
||||||
@@ -681,14 +547,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines = create_diff_summary(
|
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72);
|
||||||
&changes,
|
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: true,
|
|
||||||
},
|
|
||||||
&PathBuf::from("/"),
|
|
||||||
72,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
|
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
|
||||||
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
|
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
|
||||||
@@ -711,14 +570,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut lines = create_diff_summary(
|
let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
|
||||||
&changes,
|
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: true,
|
|
||||||
},
|
|
||||||
&PathBuf::from("/"),
|
|
||||||
28,
|
|
||||||
);
|
|
||||||
// Drop the combined header for this text-only snapshot
|
// Drop the combined header for this text-only snapshot
|
||||||
if !lines.is_empty() {
|
if !lines.is_empty() {
|
||||||
lines.remove(0);
|
lines.remove(0);
|
||||||
@@ -745,14 +597,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let lines = create_diff_summary(
|
let lines = create_diff_summary(&changes, &cwd, 80);
|
||||||
&changes,
|
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: true,
|
|
||||||
},
|
|
||||||
&cwd,
|
|
||||||
80,
|
|
||||||
);
|
|
||||||
|
|
||||||
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
|
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use crate::exec_cell::TOOL_CALL_MAX_LINES;
|
|||||||
use crate::exec_cell::output_lines;
|
use crate::exec_cell::output_lines;
|
||||||
use crate::exec_cell::spinner;
|
use crate::exec_cell::spinner;
|
||||||
use crate::exec_command::relativize_to_home;
|
use crate::exec_command::relativize_to_home;
|
||||||
use crate::exec_command::strip_bash_lc_and_escape;
|
|
||||||
use crate::markdown::MarkdownCitationContext;
|
use crate::markdown::MarkdownCitationContext;
|
||||||
use crate::markdown::append_markdown;
|
use crate::markdown::append_markdown;
|
||||||
use crate::render::line_utils::line_to_static;
|
use crate::render::line_utils::line_to_static;
|
||||||
@@ -50,12 +49,6 @@ use std::time::Instant;
|
|||||||
use tracing::error;
|
use tracing::error;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) enum PatchEventType {
|
|
||||||
ApprovalRequest,
|
|
||||||
ApplyBegin { auto_approved: bool },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents an event to display in the conversation history. Returns its
|
/// Represents an event to display in the conversation history. Returns its
|
||||||
/// `Vec<Line<'static>>` representation to make it easier to display in a
|
/// `Vec<Line<'static>>` representation to make it easier to display in a
|
||||||
/// scrollable list.
|
/// scrollable list.
|
||||||
@@ -277,19 +270,13 @@ pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(crate) struct PatchHistoryCell {
|
pub(crate) struct PatchHistoryCell {
|
||||||
event_type: PatchEventType,
|
|
||||||
changes: HashMap<PathBuf, FileChange>,
|
changes: HashMap<PathBuf, FileChange>,
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HistoryCell for PatchHistoryCell {
|
impl HistoryCell for PatchHistoryCell {
|
||||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||||
create_diff_summary(
|
create_diff_summary(&self.changes, &self.cwd, width as usize)
|
||||||
&self.changes,
|
|
||||||
self.event_type.clone(),
|
|
||||||
&self.cwd,
|
|
||||||
width as usize,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,12 +1003,10 @@ impl HistoryCell for PlanUpdateCell {
|
|||||||
/// a proposed patch. The summary lines should already be formatted (e.g.
|
/// a proposed patch. The summary lines should already be formatted (e.g.
|
||||||
/// "A path/to/file.rs").
|
/// "A path/to/file.rs").
|
||||||
pub(crate) fn new_patch_event(
|
pub(crate) fn new_patch_event(
|
||||||
event_type: PatchEventType,
|
|
||||||
changes: HashMap<PathBuf, FileChange>,
|
changes: HashMap<PathBuf, FileChange>,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
) -> PatchHistoryCell {
|
) -> PatchHistoryCell {
|
||||||
PatchHistoryCell {
|
PatchHistoryCell {
|
||||||
event_type,
|
|
||||||
changes,
|
changes,
|
||||||
cwd: cwd.to_path_buf(),
|
cwd: cwd.to_path_buf(),
|
||||||
}
|
}
|
||||||
@@ -1052,27 +1037,6 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
|
|||||||
PlainHistoryCell { lines }
|
PlainHistoryCell { lines }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new history cell for a proposed command approval.
|
|
||||||
/// Renders a header and the command preview similar to how proposed patches
|
|
||||||
/// show a header and summary.
|
|
||||||
pub(crate) fn new_proposed_command(command: &[String]) -> PlainHistoryCell {
|
|
||||||
let cmd = strip_bash_lc_and_escape(command);
|
|
||||||
|
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
|
||||||
lines.push(Line::from(vec!["• ".dim(), "Proposed Command".bold()]));
|
|
||||||
|
|
||||||
let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd);
|
|
||||||
let initial_prefix: Span<'static> = " └ ".dim();
|
|
||||||
let subsequent_prefix: Span<'static> = " ".into();
|
|
||||||
lines.extend(prefix_lines(
|
|
||||||
highlighted_lines,
|
|
||||||
initial_prefix,
|
|
||||||
subsequent_prefix,
|
|
||||||
));
|
|
||||||
|
|
||||||
PlainHistoryCell { lines }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn new_reasoning_block(
|
pub(crate) fn new_reasoning_block(
|
||||||
full_reasoning_buffer: String,
|
full_reasoning_buffer: String,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ use std::sync::Arc;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
use crate::render::line_utils::push_owned_lines;
|
use crate::render::renderable::Renderable;
|
||||||
use crate::tui;
|
use crate::tui;
|
||||||
use crate::tui::TuiEvent;
|
use crate::tui::TuiEvent;
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use crossterm::event::KeyEventKind;
|
use crossterm::event::KeyEventKind;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::buffer::Cell;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
@@ -22,6 +23,7 @@ use ratatui::widgets::Clear;
|
|||||||
use ratatui::widgets::Paragraph;
|
use ratatui::widgets::Paragraph;
|
||||||
use ratatui::widgets::Widget;
|
use ratatui::widgets::Widget;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
use ratatui::widgets::Wrap;
|
||||||
|
|
||||||
pub(crate) enum Overlay {
|
pub(crate) enum Overlay {
|
||||||
Transcript(TranscriptOverlay),
|
Transcript(TranscriptOverlay),
|
||||||
@@ -33,10 +35,17 @@ impl Overlay {
|
|||||||
Self::Transcript(TranscriptOverlay::new(cells))
|
Self::Transcript(TranscriptOverlay::new(cells))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn new_static_with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
pub(crate) fn new_static_with_lines(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||||
Self::Static(StaticOverlay::with_title(lines, title))
|
Self::Static(StaticOverlay::with_title(lines, title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_static_with_renderables(
|
||||||
|
renderables: Vec<Box<dyn Renderable>>,
|
||||||
|
title: String,
|
||||||
|
) -> Self {
|
||||||
|
Self::Static(StaticOverlay::with_renderables(renderables, title))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||||
match self {
|
match self {
|
||||||
Overlay::Transcript(o) => o.handle_event(tui, event),
|
Overlay::Transcript(o) => o.handle_event(tui, event),
|
||||||
@@ -78,57 +87,53 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
|
|||||||
|
|
||||||
/// Generic widget for rendering a pager view.
|
/// Generic widget for rendering a pager view.
|
||||||
struct PagerView {
|
struct PagerView {
|
||||||
texts: Vec<Text<'static>>,
|
renderables: Vec<Box<dyn Renderable>>,
|
||||||
scroll_offset: usize,
|
scroll_offset: usize,
|
||||||
title: String,
|
title: String,
|
||||||
wrap_cache: Option<WrapCache>,
|
|
||||||
last_content_height: Option<usize>,
|
last_content_height: Option<usize>,
|
||||||
|
last_rendered_height: Option<usize>,
|
||||||
/// If set, on next render ensure this chunk is visible.
|
/// If set, on next render ensure this chunk is visible.
|
||||||
pending_scroll_chunk: Option<usize>,
|
pending_scroll_chunk: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PagerView {
|
impl PagerView {
|
||||||
fn new(texts: Vec<Text<'static>>, title: String, scroll_offset: usize) -> Self {
|
fn new(renderables: Vec<Box<dyn Renderable>>, title: String, scroll_offset: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
texts,
|
renderables,
|
||||||
scroll_offset,
|
scroll_offset,
|
||||||
title,
|
title,
|
||||||
wrap_cache: None,
|
|
||||||
last_content_height: None,
|
last_content_height: None,
|
||||||
|
last_rendered_height: None,
|
||||||
pending_scroll_chunk: None,
|
pending_scroll_chunk: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn content_height(&self, width: u16) -> usize {
|
||||||
|
self.renderables
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.desired_height(width) as usize)
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||||
Clear.render(area, buf);
|
Clear.render(area, buf);
|
||||||
self.render_header(area, buf);
|
self.render_header(area, buf);
|
||||||
let content_area = self.scroll_area(area);
|
let content_area = self.content_area(area);
|
||||||
self.update_last_content_height(content_area.height);
|
self.update_last_content_height(content_area.height);
|
||||||
self.ensure_wrapped(content_area.width);
|
let content_height = self.content_height(content_area.width);
|
||||||
|
self.last_rendered_height = Some(content_height);
|
||||||
// If there is a pending request to scroll a specific chunk into view,
|
// If there is a pending request to scroll a specific chunk into view,
|
||||||
// satisfy it now that wrapping is up to date for this width.
|
// satisfy it now that wrapping is up to date for this width.
|
||||||
if let (Some(idx), Some(cache)) =
|
if let Some(idx) = self.pending_scroll_chunk.take() {
|
||||||
(self.pending_scroll_chunk.take(), self.wrap_cache.as_ref())
|
self.ensure_chunk_visible(idx, content_area);
|
||||||
&& let Some(range) = cache.chunk_ranges.get(idx).cloned()
|
|
||||||
{
|
|
||||||
self.ensure_range_visible(range, content_area.height as usize, cache.wrapped.len());
|
|
||||||
}
|
}
|
||||||
// Compute page bounds without holding an immutable borrow on cache while mutating self
|
|
||||||
let wrapped_len = self
|
|
||||||
.wrap_cache
|
|
||||||
.as_ref()
|
|
||||||
.map(|c| c.wrapped.len())
|
|
||||||
.unwrap_or(0);
|
|
||||||
self.scroll_offset = self
|
self.scroll_offset = self
|
||||||
.scroll_offset
|
.scroll_offset
|
||||||
.min(wrapped_len.saturating_sub(content_area.height as usize));
|
.min(content_height.saturating_sub(content_area.height as usize));
|
||||||
let start = self.scroll_offset;
|
|
||||||
let end = (start + content_area.height as usize).min(wrapped_len);
|
|
||||||
|
|
||||||
let wrapped = self.cached();
|
self.render_content(content_area, buf);
|
||||||
let page = &wrapped[start..end];
|
|
||||||
self.render_content_page_prepared(content_area, buf, page);
|
self.render_bottom_bar(area, content_area, buf, content_height);
|
||||||
self.render_bottom_bar(area, content_area, buf, wrapped);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(&self, area: Rect, buf: &mut Buffer) {
|
fn render_header(&self, area: Rect, buf: &mut Buffer) {
|
||||||
@@ -139,20 +144,38 @@ impl PagerView {
|
|||||||
header.dim().render_ref(area, buf);
|
header.dim().render_ref(area, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed unused render_content_page (replaced by render_content_page_prepared)
|
fn render_content(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let mut y = -(self.scroll_offset as isize);
|
||||||
|
let mut drawn_bottom = area.y;
|
||||||
|
for renderable in &self.renderables {
|
||||||
|
let top = y;
|
||||||
|
let height = renderable.desired_height(area.width) as isize;
|
||||||
|
y += height;
|
||||||
|
let bottom = y;
|
||||||
|
if bottom < area.y as isize {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if top > area.y as isize + area.height as isize {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if top < 0 {
|
||||||
|
let drawn = render_offset_content(area, buf, &**renderable, (-top) as u16);
|
||||||
|
drawn_bottom = drawn_bottom.max(area.y + drawn);
|
||||||
|
} else {
|
||||||
|
let draw_height = (height as u16).min(area.height.saturating_sub(top as u16));
|
||||||
|
let draw_area = Rect::new(area.x, area.y + top as u16, area.width, draw_height);
|
||||||
|
renderable.render(draw_area, buf);
|
||||||
|
drawn_bottom = drawn_bottom.max(draw_area.y.saturating_add(draw_area.height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn render_content_page_prepared(&self, area: Rect, buf: &mut Buffer, page: &[Line<'static>]) {
|
for y in drawn_bottom..area.bottom() {
|
||||||
Clear.render(area, buf);
|
if area.width == 0 {
|
||||||
Paragraph::new(page.to_vec()).render_ref(area, buf);
|
break;
|
||||||
|
}
|
||||||
let visible = page.len();
|
buf[(area.x, y)] = Cell::from('~');
|
||||||
if visible < area.height as usize {
|
for x in area.x + 1..area.right() {
|
||||||
for i in 0..(area.height as usize - visible) {
|
buf[(x, y)] = Cell::from(' ');
|
||||||
let add = ((visible + i).min(u16::MAX as usize)) as u16;
|
|
||||||
let y = area.y.saturating_add(add);
|
|
||||||
Span::from("~")
|
|
||||||
.dim()
|
|
||||||
.render_ref(Rect::new(area.x, y, 1, 1), buf);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +185,7 @@ impl PagerView {
|
|||||||
full_area: Rect,
|
full_area: Rect,
|
||||||
content_area: Rect,
|
content_area: Rect,
|
||||||
buf: &mut Buffer,
|
buf: &mut Buffer,
|
||||||
wrapped: &[Line<'static>],
|
total_len: usize,
|
||||||
) {
|
) {
|
||||||
let sep_y = content_area.bottom();
|
let sep_y = content_area.bottom();
|
||||||
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
|
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
|
||||||
@@ -170,10 +193,10 @@ impl PagerView {
|
|||||||
Span::from("─".repeat(sep_rect.width as usize))
|
Span::from("─".repeat(sep_rect.width as usize))
|
||||||
.dim()
|
.dim()
|
||||||
.render_ref(sep_rect, buf);
|
.render_ref(sep_rect, buf);
|
||||||
let percent = if wrapped.is_empty() {
|
let percent = if total_len == 0 {
|
||||||
100
|
100
|
||||||
} else {
|
} else {
|
||||||
let max_scroll = wrapped.len().saturating_sub(content_area.height as usize);
|
let max_scroll = total_len.saturating_sub(content_area.height as usize);
|
||||||
if max_scroll == 0 {
|
if max_scroll == 0 {
|
||||||
100
|
100
|
||||||
} else {
|
} else {
|
||||||
@@ -210,7 +233,7 @@ impl PagerView {
|
|||||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let area = self.scroll_area(tui.terminal.viewport_area);
|
let area = self.content_area(tui.terminal.viewport_area);
|
||||||
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
|
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
|
||||||
}
|
}
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
@@ -218,7 +241,7 @@ impl PagerView {
|
|||||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let area = self.scroll_area(tui.terminal.viewport_area);
|
let area = self.content_area(tui.terminal.viewport_area);
|
||||||
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
|
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
|
||||||
}
|
}
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
@@ -248,7 +271,7 @@ impl PagerView {
|
|||||||
self.last_content_height = Some(height as usize);
|
self.last_content_height = Some(height as usize);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_area(&self, area: Rect) -> Rect {
|
fn content_area(&self, area: Rect) -> Rect {
|
||||||
let mut area = area;
|
let mut area = area;
|
||||||
area.y = area.y.saturating_add(1);
|
area.y = area.y.saturating_add(1);
|
||||||
area.height = area.height.saturating_sub(2);
|
area.height = area.height.saturating_sub(2);
|
||||||
@@ -256,67 +279,24 @@ impl PagerView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
struct WrapCache {
|
|
||||||
width: u16,
|
|
||||||
wrapped: Vec<Line<'static>>,
|
|
||||||
/// For each input Text chunk, the inclusive-excluded range of wrapped lines produced.
|
|
||||||
chunk_ranges: Vec<std::ops::Range<usize>>,
|
|
||||||
base_len: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PagerView {
|
impl PagerView {
|
||||||
fn ensure_wrapped(&mut self, width: u16) {
|
|
||||||
let width = width.max(1);
|
|
||||||
let needs = match self.wrap_cache {
|
|
||||||
Some(ref c) => c.width != width || c.base_len != self.texts.len(),
|
|
||||||
None => true,
|
|
||||||
};
|
|
||||||
if !needs {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mut wrapped: Vec<Line<'static>> = Vec::new();
|
|
||||||
let mut chunk_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(self.texts.len());
|
|
||||||
for text in &self.texts {
|
|
||||||
let start = wrapped.len();
|
|
||||||
for line in &text.lines {
|
|
||||||
let ws = crate::wrapping::word_wrap_line(line, width as usize);
|
|
||||||
push_owned_lines(&ws, &mut wrapped);
|
|
||||||
}
|
|
||||||
let end = wrapped.len();
|
|
||||||
chunk_ranges.push(start..end);
|
|
||||||
}
|
|
||||||
self.wrap_cache = Some(WrapCache {
|
|
||||||
width,
|
|
||||||
wrapped,
|
|
||||||
chunk_ranges,
|
|
||||||
base_len: self.texts.len(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cached(&self) -> &[Line<'static>] {
|
|
||||||
if let Some(cache) = self.wrap_cache.as_ref() {
|
|
||||||
&cache.wrapped
|
|
||||||
} else {
|
|
||||||
&[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_scrolled_to_bottom(&self) -> bool {
|
fn is_scrolled_to_bottom(&self) -> bool {
|
||||||
if self.scroll_offset == usize::MAX {
|
if self.scroll_offset == usize::MAX {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let Some(cache) = &self.wrap_cache else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Some(height) = self.last_content_height else {
|
let Some(height) = self.last_content_height else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if cache.wrapped.is_empty() {
|
if self.renderables.is_empty() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
let visible = height.min(cache.wrapped.len());
|
let Some(total_height) = self.last_rendered_height else {
|
||||||
let max_scroll = cache.wrapped.len().saturating_sub(visible);
|
return false;
|
||||||
|
};
|
||||||
|
if total_height <= height {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let max_scroll = total_height.saturating_sub(height);
|
||||||
self.scroll_offset >= max_scroll
|
self.scroll_offset >= max_scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,32 +305,57 @@ impl PagerView {
|
|||||||
self.pending_scroll_chunk = Some(chunk_index);
|
self.pending_scroll_chunk = Some(chunk_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_range_visible(
|
fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
|
||||||
&mut self,
|
if area.height == 0 || idx >= self.renderables.len() {
|
||||||
range: std::ops::Range<usize>,
|
|
||||||
viewport_height: usize,
|
|
||||||
total_wrapped: usize,
|
|
||||||
) {
|
|
||||||
if viewport_height == 0 || total_wrapped == 0 {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let first = range.start.min(total_wrapped.saturating_sub(1));
|
let first = self
|
||||||
let last = range
|
.renderables
|
||||||
.end
|
.iter()
|
||||||
.saturating_sub(1)
|
.take(idx)
|
||||||
.min(total_wrapped.saturating_sub(1));
|
.map(|r| r.desired_height(area.width) as usize)
|
||||||
let current_top = self.scroll_offset.min(total_wrapped.saturating_sub(1));
|
.sum();
|
||||||
let current_bottom = current_top.saturating_add(viewport_height.saturating_sub(1));
|
let last = first + self.renderables[idx].desired_height(area.width) as usize;
|
||||||
|
let current_top = self.scroll_offset;
|
||||||
|
let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize);
|
||||||
if first < current_top {
|
if first < current_top {
|
||||||
self.scroll_offset = first;
|
self.scroll_offset = first;
|
||||||
} else if last > current_bottom {
|
} else if last > current_bottom {
|
||||||
// Scroll just enough so that 'last' is visible at the bottom
|
self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
|
||||||
self.scroll_offset = last.saturating_sub(viewport_height.saturating_sub(1));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CachedParagraph {
|
||||||
|
paragraph: Paragraph<'static>,
|
||||||
|
height: std::cell::Cell<Option<u16>>,
|
||||||
|
last_width: std::cell::Cell<Option<u16>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CachedParagraph {
|
||||||
|
fn new(paragraph: Paragraph<'static>) -> Self {
|
||||||
|
Self {
|
||||||
|
paragraph,
|
||||||
|
height: std::cell::Cell::new(None),
|
||||||
|
last_width: std::cell::Cell::new(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Renderable for CachedParagraph {
|
||||||
|
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
self.paragraph.render_ref(area, buf);
|
||||||
|
}
|
||||||
|
fn desired_height(&self, width: u16) -> u16 {
|
||||||
|
if self.last_width.get() != Some(width) {
|
||||||
|
let height = self.paragraph.line_count(width) as u16;
|
||||||
|
self.height.set(Some(height));
|
||||||
|
self.last_width.set(Some(width));
|
||||||
|
}
|
||||||
|
self.height.get().unwrap_or(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) struct TranscriptOverlay {
|
pub(crate) struct TranscriptOverlay {
|
||||||
view: PagerView,
|
view: PagerView,
|
||||||
cells: Vec<Arc<dyn HistoryCell>>,
|
cells: Vec<Arc<dyn HistoryCell>>,
|
||||||
@@ -375,8 +380,8 @@ impl TranscriptOverlay {
|
|||||||
fn render_cells_to_texts(
|
fn render_cells_to_texts(
|
||||||
cells: &[Arc<dyn HistoryCell>],
|
cells: &[Arc<dyn HistoryCell>],
|
||||||
highlight_cell: Option<usize>,
|
highlight_cell: Option<usize>,
|
||||||
) -> Vec<Text<'static>> {
|
) -> Vec<Box<dyn Renderable>> {
|
||||||
let mut texts: Vec<Text<'static>> = Vec::new();
|
let mut texts: Vec<Box<dyn Renderable>> = Vec::new();
|
||||||
let mut first = true;
|
let mut first = true;
|
||||||
for (idx, cell) in cells.iter().enumerate() {
|
for (idx, cell) in cells.iter().enumerate() {
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
@@ -392,7 +397,9 @@ impl TranscriptOverlay {
|
|||||||
cell.transcript_lines()
|
cell.transcript_lines()
|
||||||
};
|
};
|
||||||
lines.extend(cell_lines);
|
lines.extend(cell_lines);
|
||||||
texts.push(Text::from(lines));
|
texts.push(Box::new(CachedParagraph::new(
|
||||||
|
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
|
||||||
|
)));
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
texts
|
texts
|
||||||
@@ -406,9 +413,10 @@ impl TranscriptOverlay {
|
|||||||
lines.push(Line::from(""));
|
lines.push(Line::from(""));
|
||||||
}
|
}
|
||||||
lines.extend(cell.transcript_lines());
|
lines.extend(cell.transcript_lines());
|
||||||
self.view.texts.push(Text::from(lines));
|
self.view.renderables.push(Box::new(CachedParagraph::new(
|
||||||
|
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
|
||||||
|
)));
|
||||||
self.cells.push(cell);
|
self.cells.push(cell);
|
||||||
self.view.wrap_cache = None;
|
|
||||||
if follow_bottom {
|
if follow_bottom {
|
||||||
self.view.scroll_offset = usize::MAX;
|
self.view.scroll_offset = usize::MAX;
|
||||||
}
|
}
|
||||||
@@ -416,8 +424,7 @@ impl TranscriptOverlay {
|
|||||||
|
|
||||||
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
|
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
|
||||||
self.highlight_cell = cell;
|
self.highlight_cell = cell;
|
||||||
self.view.wrap_cache = None;
|
self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
|
||||||
self.view.texts = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
|
|
||||||
if let Some(idx) = self.highlight_cell {
|
if let Some(idx) = self.highlight_cell {
|
||||||
self.view.scroll_chunk_into_view(idx);
|
self.view.scroll_chunk_into_view(idx);
|
||||||
}
|
}
|
||||||
@@ -490,8 +497,17 @@ pub(crate) struct StaticOverlay {
|
|||||||
|
|
||||||
impl StaticOverlay {
|
impl StaticOverlay {
|
||||||
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||||
|
Self::with_renderables(
|
||||||
|
vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from(
|
||||||
|
lines,
|
||||||
|
))))],
|
||||||
|
title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_renderables(renderables: Vec<Box<dyn Renderable>>, title: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
view: PagerView::new(vec![Text::from(lines)], title, 0),
|
view: PagerView::new(renderables, title, 0),
|
||||||
is_done: false,
|
is_done: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,6 +563,33 @@ impl StaticOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_offset_content(
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
renderable: &dyn Renderable,
|
||||||
|
scroll_offset: u16,
|
||||||
|
) -> u16 {
|
||||||
|
let height = renderable.desired_height(area.width);
|
||||||
|
let mut tall_buf = Buffer::empty(Rect::new(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
area.width,
|
||||||
|
height.min(area.height + scroll_offset),
|
||||||
|
));
|
||||||
|
renderable.render(*tall_buf.area(), &mut tall_buf);
|
||||||
|
let copy_height = area
|
||||||
|
.height
|
||||||
|
.min(tall_buf.area().height.saturating_sub(scroll_offset));
|
||||||
|
for y in 0..copy_height {
|
||||||
|
let src_y = y + scroll_offset;
|
||||||
|
for x in 0..area.width {
|
||||||
|
buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_height
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -558,12 +601,12 @@ mod tests {
|
|||||||
|
|
||||||
use crate::exec_cell::CommandOutput;
|
use crate::exec_cell::CommandOutput;
|
||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
use crate::history_cell::PatchEventType;
|
|
||||||
use crate::history_cell::new_patch_event;
|
use crate::history_cell::new_patch_event;
|
||||||
use codex_core::protocol::FileChange;
|
use codex_core::protocol::FileChange;
|
||||||
use codex_protocol::parse_command::ParsedCommand;
|
use codex_protocol::parse_command::ParsedCommand;
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::TestBackend;
|
use ratatui::backend::TestBackend;
|
||||||
|
use ratatui::text::Text;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TestCell {
|
struct TestCell {
|
||||||
@@ -580,6 +623,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn paragraph_block(label: &str, lines: usize) -> Box<dyn Renderable> {
|
||||||
|
let text = Text::from(
|
||||||
|
(0..lines)
|
||||||
|
.map(|i| Line::from(format!("{label}{i}")))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
Box::new(Paragraph::new(text)) as Box<dyn Renderable>
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn edit_prev_hint_is_visible() {
|
fn edit_prev_hint_is_visible() {
|
||||||
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
|
||||||
@@ -657,11 +709,7 @@ mod tests {
|
|||||||
content: "hello\nworld\n".to_string(),
|
content: "hello\nworld\n".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(
|
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(approval_changes, &cwd));
|
||||||
PatchEventType::ApprovalRequest,
|
|
||||||
approval_changes,
|
|
||||||
&cwd,
|
|
||||||
));
|
|
||||||
cells.push(approval_cell);
|
cells.push(approval_cell);
|
||||||
|
|
||||||
let mut apply_changes = HashMap::new();
|
let mut apply_changes = HashMap::new();
|
||||||
@@ -671,13 +719,7 @@ mod tests {
|
|||||||
content: "hello\nworld\n".to_string(),
|
content: "hello\nworld\n".to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(
|
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(apply_changes, &cwd));
|
||||||
PatchEventType::ApplyBegin {
|
|
||||||
auto_approved: false,
|
|
||||||
},
|
|
||||||
apply_changes,
|
|
||||||
&cwd,
|
|
||||||
));
|
|
||||||
cells.push(apply_begin_cell);
|
cells.push(apply_begin_cell);
|
||||||
|
|
||||||
let apply_end_cell: Arc<dyn HistoryCell> =
|
let apply_end_cell: Arc<dyn HistoryCell> =
|
||||||
@@ -711,7 +753,6 @@ mod tests {
|
|||||||
|
|
||||||
overlay.render(area, &mut buf);
|
overlay.render(area, &mut buf);
|
||||||
overlay.view.scroll_offset = 0;
|
overlay.view.scroll_offset = 0;
|
||||||
overlay.view.wrap_cache = None;
|
|
||||||
overlay.render(area, &mut buf);
|
overlay.render(area, &mut buf);
|
||||||
|
|
||||||
let snapshot = buffer_to_text(&buf, area);
|
let snapshot = buffer_to_text(&buf, area);
|
||||||
@@ -783,54 +824,89 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() {
|
fn pager_view_content_height_counts_renderables() {
|
||||||
let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output.";
|
let pv = PagerView::new(
|
||||||
let mut pv = PagerView::new(
|
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
|
||||||
vec![Text::from(vec![long.into()]), Text::from(vec![long.into()])],
|
|
||||||
"T".to_string(),
|
"T".to_string(),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build cache at width 24
|
assert_eq!(pv.content_height(80), 5);
|
||||||
pv.ensure_wrapped(24);
|
}
|
||||||
let w1 = pv.cached();
|
|
||||||
assert!(!w1.is_empty(), "expected wrapped output to be non-empty");
|
|
||||||
let ptr1 = w1.as_ptr();
|
|
||||||
|
|
||||||
// Re-run with same width: cache should be reused (pointer stability heuristic)
|
#[test]
|
||||||
pv.ensure_wrapped(24);
|
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
|
||||||
let w2 = pv.cached();
|
let mut pv = PagerView::new(
|
||||||
let ptr2 = w2.as_ptr();
|
vec![
|
||||||
assert_eq!(ptr1, ptr2, "cache should not rebuild for unchanged width");
|
paragraph_block("a", 1),
|
||||||
|
paragraph_block("b", 3),
|
||||||
|
paragraph_block("c", 3),
|
||||||
|
],
|
||||||
|
"T".to_string(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let area = Rect::new(0, 0, 20, 8);
|
||||||
|
|
||||||
// Change width: cache should rebuild and likely produce different length
|
pv.scroll_offset = 0;
|
||||||
// Drop immutable borrow before mutating
|
let content_area = pv.content_area(area);
|
||||||
let prev_len = w2.len();
|
pv.ensure_chunk_visible(2, content_area);
|
||||||
pv.ensure_wrapped(36);
|
|
||||||
let w3 = pv.cached();
|
let mut buf = Buffer::empty(area);
|
||||||
assert_ne!(
|
pv.render(area, &mut buf);
|
||||||
prev_len,
|
let rendered = buffer_to_text(&buf, area);
|
||||||
w3.len(),
|
|
||||||
"wrapped length should change on width change"
|
assert!(
|
||||||
|
rendered.contains("c0"),
|
||||||
|
"expected chunk top in view: {rendered:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains("c1"),
|
||||||
|
"expected chunk middle in view: {rendered:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains("c2"),
|
||||||
|
"expected chunk bottom in view: {rendered:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pager_wrap_cache_invalidates_on_append() {
|
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
|
||||||
let long = "Another long line for wrapping behavior verification.";
|
let mut pv = PagerView::new(
|
||||||
let mut pv = PagerView::new(vec![Text::from(vec![long.into()])], "T".to_string(), 0);
|
vec![
|
||||||
pv.ensure_wrapped(28);
|
paragraph_block("a", 2),
|
||||||
let w1 = pv.cached();
|
paragraph_block("b", 3),
|
||||||
let len1 = w1.len();
|
paragraph_block("c", 3),
|
||||||
|
],
|
||||||
|
"T".to_string(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let area = Rect::new(0, 0, 20, 3);
|
||||||
|
|
||||||
|
pv.scroll_offset = 6;
|
||||||
|
pv.ensure_chunk_visible(0, area);
|
||||||
|
|
||||||
|
assert_eq!(pv.scroll_offset, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
|
||||||
|
let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 0);
|
||||||
|
let area = Rect::new(0, 0, 20, 8);
|
||||||
|
let mut buf = Buffer::empty(area);
|
||||||
|
|
||||||
|
pv.render(area, &mut buf);
|
||||||
|
|
||||||
// Append new lines should cause ensure_wrapped to rebuild due to len change
|
|
||||||
pv.texts.push(Text::from(vec![long.into()]));
|
|
||||||
pv.texts.push(Text::from(vec![long.into()]));
|
|
||||||
pv.ensure_wrapped(28);
|
|
||||||
let w2 = pv.cached();
|
|
||||||
assert!(
|
assert!(
|
||||||
w2.len() >= len1,
|
!pv.is_scrolled_to_bottom(),
|
||||||
"wrapped length should grow or stay same after append"
|
"expected view to report not at bottom when offset < max"
|
||||||
|
);
|
||||||
|
|
||||||
|
pv.scroll_offset = usize::MAX;
|
||||||
|
pv.render(area, &mut buf);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
pv.is_scrolled_to_bottom(),
|
||||||
|
"expected view to report at bottom after scrolling to end"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,47 @@
|
|||||||
|
use ratatui::layout::Rect;
|
||||||
|
|
||||||
pub mod highlight;
|
pub mod highlight;
|
||||||
pub mod line_utils;
|
pub mod line_utils;
|
||||||
|
pub mod renderable;
|
||||||
|
|
||||||
|
pub struct Insets {
|
||||||
|
pub left: u16,
|
||||||
|
pub top: u16,
|
||||||
|
pub right: u16,
|
||||||
|
pub bottom: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Insets {
|
||||||
|
pub fn tlbr(top: u16, left: u16, bottom: u16, right: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
bottom,
|
||||||
|
right,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vh(v: u16, h: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
top: v,
|
||||||
|
left: h,
|
||||||
|
bottom: v,
|
||||||
|
right: h,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait RectExt {
|
||||||
|
fn inset(&self, insets: Insets) -> Rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RectExt for Rect {
|
||||||
|
fn inset(&self, insets: Insets) -> Rect {
|
||||||
|
Rect {
|
||||||
|
x: self.x + insets.left,
|
||||||
|
y: self.y + insets.top,
|
||||||
|
width: self.width - insets.left - insets.right,
|
||||||
|
height: self.height - insets.top - insets.bottom,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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
|
source: tui/src/diff_render.rs
|
||||||
expression: terminal.backend()
|
expression: terminal.backend()
|
||||||
---
|
---
|
||||||
"• Change Approved example.txt (+1 -1) "
|
"• Edited example.txt (+1 -1) "
|
||||||
" "
|
" 1 line one "
|
||||||
" "
|
" 2 -line two "
|
||||||
" "
|
" 2 +line two changed "
|
||||||
" "
|
" 3 line three "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
" "
|
" "
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
---
|
---
|
||||||
source: tui/src/pager_overlay.rs
|
source: tui/src/pager_overlay.rs
|
||||||
assertion_line: 721
|
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
|
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
|
||||||
• Proposed Change foo.txt (+2 -0)
|
• Added foo.txt (+2 -0)
|
||||||
1 +hello
|
1 +hello
|
||||||
2 +world
|
2 +world
|
||||||
|
|
||||||
• Change Approved foo.txt (+2 -0)
|
• Added foo.txt (+2 -0)
|
||||||
|
1 +hello
|
||||||
✓ Patch applied
|
2 +world
|
||||||
─────────────────────────────────────────────────────────────────────────── 0% ─
|
─────────────────────────────────────────────────────────────────────────── 0% ─
|
||||||
↑/↓ scroll PgUp/PgDn page Home/End jump
|
↑/↓ scroll PgUp/PgDn page Home/End jump
|
||||||
q quit Esc edit prev
|
q quit Esc edit prev
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use crate::app_event_sender::AppEventSender;
|
|||||||
use crate::key_hint;
|
use crate::key_hint;
|
||||||
use crate::shimmer::shimmer_spans;
|
use crate::shimmer::shimmer_spans;
|
||||||
use crate::tui::FrameRequester;
|
use crate::tui::FrameRequester;
|
||||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
|
||||||
|
|
||||||
pub(crate) struct StatusIndicatorWidget {
|
pub(crate) struct StatusIndicatorWidget {
|
||||||
/// Animated header text (defaults to "Working").
|
/// Animated header text (defaults to "Working").
|
||||||
@@ -160,7 +159,7 @@ impl WidgetRef for StatusIndicatorWidget {
|
|||||||
let pretty_elapsed = fmt_elapsed_compact(elapsed);
|
let pretty_elapsed = fmt_elapsed_compact(elapsed);
|
||||||
|
|
||||||
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
||||||
let mut spans = vec![" ".repeat(LIVE_PREFIX_COLS as usize).into()];
|
let mut spans = vec![" ".into()];
|
||||||
spans.extend(shimmer_spans(&self.header));
|
spans.extend(shimmer_spans(&self.header));
|
||||||
spans.extend(vec![
|
spans.extend(vec![
|
||||||
" ".into(),
|
" ".into(),
|
||||||
|
|||||||
Reference in New Issue
Block a user