mirror of
https://github.com/openai/codex.git
synced 2026-02-02 06:57:03 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfd292c424 |
@@ -94,8 +94,14 @@ impl ApprovalOverlay {
|
||||
);
|
||||
};
|
||||
let (options, title) = match &state.variant {
|
||||
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
|
||||
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
|
||||
ApprovalVariant::Exec { .. } => (
|
||||
exec_options(),
|
||||
"Would you like to run the following command?".to_string(),
|
||||
),
|
||||
ApprovalVariant::ApplyPatch { .. } => (
|
||||
patch_options(),
|
||||
"Would you like to apply these changes?".to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let items = options
|
||||
@@ -110,9 +116,14 @@ impl ApprovalOverlay {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let footer_hint = match &state.variant {
|
||||
ApprovalVariant::Exec { .. } => "Press Enter to continue".to_string(),
|
||||
ApprovalVariant::ApplyPatch { .. } => "Press Enter to continue".to_string(),
|
||||
};
|
||||
|
||||
let params = SelectionViewParams {
|
||||
title,
|
||||
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
|
||||
footer_hint: Some(footer_hint),
|
||||
items,
|
||||
header: state.header.clone(),
|
||||
..Default::default()
|
||||
@@ -281,9 +292,8 @@ impl From<ApprovalRequest> for ApprovalRequestState {
|
||||
}
|
||||
let command_snippet = exec_snippet(&command);
|
||||
if !command_snippet.is_empty() {
|
||||
header.push(HeaderLine::Text {
|
||||
text: format!("Command: {command_snippet}"),
|
||||
italic: false,
|
||||
header.push(HeaderLine::Command {
|
||||
command: command_snippet,
|
||||
});
|
||||
header.push(HeaderLine::Spacer);
|
||||
}
|
||||
@@ -529,7 +539,7 @@ mod tests {
|
||||
assert!(
|
||||
rendered
|
||||
.iter()
|
||||
.any(|line| line.contains("Command: echo hello world")),
|
||||
.any(|line| line.contains("$ echo hello world")),
|
||||
"expected header to include command snippet, got {rendered:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
@@ -95,7 +96,7 @@ impl CommandPopup {
|
||||
use super::selection_popup_common::measure_rows_height;
|
||||
let rows = self.rows_from_matches(self.filtered());
|
||||
|
||||
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width)
|
||||
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width, LIVE_PREFIX_COLS)
|
||||
}
|
||||
|
||||
/// Compute fuzzy-filtered matches over built-in commands and user prompts,
|
||||
@@ -212,6 +213,7 @@ impl WidgetRef for CommandPopup {
|
||||
MAX_POPUP_ROWS,
|
||||
"no matches",
|
||||
false,
|
||||
LIVE_PREFIX_COLS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
use super::selection_popup_common::GenericDisplayRow;
|
||||
use super::selection_popup_common::render_rows;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
|
||||
/// Visual state for the file-search popup.
|
||||
pub(crate) struct FileSearchPopup {
|
||||
@@ -146,6 +147,7 @@ impl WidgetRef for &FileSearchPopup {
|
||||
MAX_POPUP_ROWS,
|
||||
empty_message,
|
||||
false,
|
||||
LIVE_PREFIX_COLS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use ratatui::widgets::Widget;
|
||||
use textwrap::wrap;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::render::border::draw_history_border;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
@@ -26,6 +27,7 @@ pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum HeaderLine {
|
||||
Text { text: String, italic: bool },
|
||||
Command { command: String },
|
||||
Spacer,
|
||||
}
|
||||
|
||||
@@ -66,15 +68,6 @@ pub(crate) struct 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 {
|
||||
let mut s = Self {
|
||||
title: params.title,
|
||||
@@ -171,7 +164,7 @@ impl ListSelectionView {
|
||||
.filter_map(|(visible_idx, actual_idx)| {
|
||||
self.items.get(*actual_idx).map(|item| {
|
||||
let is_selected = self.state.selected_idx == Some(visible_idx);
|
||||
let prefix = if is_selected { '>' } else { ' ' };
|
||||
let prefix = if is_selected { '›' } else { ' ' };
|
||||
let name = item.name.as_str();
|
||||
let name_with_marker = if item.is_current {
|
||||
format!("{name} (current)")
|
||||
@@ -236,8 +229,7 @@ impl ListSelectionView {
|
||||
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 available = width.max(1) as usize;
|
||||
let mut lines = Vec::new();
|
||||
for entry in &self.header {
|
||||
match entry {
|
||||
@@ -256,6 +248,22 @@ impl ListSelectionView {
|
||||
lines.push(vec![span]);
|
||||
}
|
||||
}
|
||||
HeaderLine::Command { command } => {
|
||||
if command.is_empty() {
|
||||
lines.push(Vec::new());
|
||||
continue;
|
||||
}
|
||||
let prompt_width = 2usize;
|
||||
let content_width = available.saturating_sub(prompt_width).max(1);
|
||||
let parts = wrap(command, content_width);
|
||||
for (idx, part) in parts.into_iter().enumerate() {
|
||||
let mut spans = Vec::new();
|
||||
let prefix = if idx == 0 { "$ " } else { " " };
|
||||
spans.push(Span::from(prefix).dim());
|
||||
spans.push(Span::from(part.into_owned()));
|
||||
lines.push(spans);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lines
|
||||
@@ -264,6 +272,28 @@ impl ListSelectionView {
|
||||
fn header_height(&self, width: u16) -> u16 {
|
||||
self.header_spans_for_width(width).len() as u16
|
||||
}
|
||||
|
||||
fn push_line(
|
||||
buf: &mut Buffer,
|
||||
inner: Rect,
|
||||
cursor_y: &mut u16,
|
||||
inner_bottom: u16,
|
||||
line: Line<'static>,
|
||||
) {
|
||||
if *cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
Paragraph::new(line).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: *cursor_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
*cursor_y = (*cursor_y).saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for ListSelectionView {
|
||||
@@ -318,155 +348,161 @@ impl BottomPaneView for ListSelectionView {
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
|
||||
// Build the same display rows used by the renderer so wrapping math matches.
|
||||
let inner_width = width.saturating_sub(4);
|
||||
if inner_width == 0 {
|
||||
return 3;
|
||||
}
|
||||
let rows = self.build_rows();
|
||||
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, inner_width, 0);
|
||||
|
||||
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
|
||||
|
||||
// +1 for the title row, +1 for a spacer line beneath the header,
|
||||
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
|
||||
let mut height = self.header_height(width);
|
||||
height = height.saturating_add(rows_height + 2);
|
||||
let mut height = self.header_height(inner_width);
|
||||
height = height.saturating_add(1); // title
|
||||
if self.is_searchable {
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
if self.subtitle.is_some() {
|
||||
// +1 for subtitle (the spacer is accounted for above)
|
||||
height = height.saturating_add(1);
|
||||
}
|
||||
height = height.saturating_add(1); // spacer between metadata and rows
|
||||
height = height.saturating_add(rows_height);
|
||||
if self.footer_hint.is_some() {
|
||||
height = height.saturating_add(2);
|
||||
}
|
||||
height
|
||||
height = height.saturating_add(2); // top + bottom border
|
||||
height.max(3)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.height == 0 || area.width == 0 {
|
||||
if area.height < 3 || area.width < 4 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut next_y = area.y;
|
||||
let header_spans = self.header_spans_for_width(area.width);
|
||||
for spans in header_spans.into_iter() {
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let row = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
|
||||
if spans.is_empty() {
|
||||
prefixed.push(String::new().into());
|
||||
} else {
|
||||
prefixed.extend(spans);
|
||||
}
|
||||
Paragraph::new(Line::from(prefixed)).render(row, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if next_y >= area.y + area.height {
|
||||
let Some(inner) = draw_history_border(buf, area) else {
|
||||
return;
|
||||
}
|
||||
|
||||
let title_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(Line::from(vec![
|
||||
Self::dim_prefix_span(),
|
||||
self.title.clone().bold(),
|
||||
]))
|
||||
.render(title_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
if inner.width == 0 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
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 mut cursor_y = inner.y;
|
||||
let inner_bottom = inner.y.saturating_add(inner.height);
|
||||
|
||||
for spans in self.header_spans_for_width(inner.width) {
|
||||
if cursor_y >= inner_bottom {
|
||||
break;
|
||||
}
|
||||
let line = if spans.is_empty() {
|
||||
Line::from(String::new())
|
||||
} else {
|
||||
Line::from(spans)
|
||||
};
|
||||
Self::push_line(buf, inner, &mut cursor_y, inner_bottom, line);
|
||||
}
|
||||
|
||||
if cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
Self::push_line(
|
||||
buf,
|
||||
inner,
|
||||
&mut cursor_y,
|
||||
inner_bottom,
|
||||
Line::from(self.title.clone().bold()),
|
||||
);
|
||||
|
||||
if cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
if self.is_searchable {
|
||||
let query_span: Span<'static> = if self.search_query.is_empty() {
|
||||
self.search_placeholder
|
||||
.as_ref()
|
||||
.map(|placeholder| placeholder.clone().dim())
|
||||
.unwrap_or_else(|| "".into())
|
||||
.unwrap_or_else(|| String::new().into())
|
||||
} else {
|
||||
self.search_query.clone().into()
|
||||
};
|
||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
|
||||
.render(search_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if let Some(sub) = &self.subtitle {
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let subtitle_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
|
||||
.render(subtitle_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
}
|
||||
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let spacer_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Self::render_dim_prefix_line(spacer_area, buf);
|
||||
next_y = next_y.saturating_add(1);
|
||||
|
||||
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
|
||||
if next_y >= area.y + area.height {
|
||||
return;
|
||||
}
|
||||
let rows_area = Rect {
|
||||
x: area.x,
|
||||
y: next_y,
|
||||
width: area.width,
|
||||
height: area
|
||||
.height
|
||||
.saturating_sub(next_y.saturating_sub(area.y))
|
||||
.saturating_sub(footer_reserved),
|
||||
};
|
||||
|
||||
let rows = self.build_rows();
|
||||
if rows_area.height > 0 {
|
||||
render_rows(
|
||||
rows_area,
|
||||
Self::push_line(
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
"no matches",
|
||||
true,
|
||||
inner,
|
||||
&mut cursor_y,
|
||||
inner_bottom,
|
||||
Line::from(vec![query_span]),
|
||||
);
|
||||
}
|
||||
|
||||
if cursor_y >= inner_bottom {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(sub) = &self.subtitle {
|
||||
Self::push_line(
|
||||
buf,
|
||||
inner,
|
||||
&mut cursor_y,
|
||||
inner_bottom,
|
||||
Line::from(sub.clone().dim()),
|
||||
);
|
||||
}
|
||||
|
||||
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
|
||||
let mut rows_height = inner_bottom
|
||||
.saturating_sub(cursor_y)
|
||||
.saturating_sub(footer_reserved);
|
||||
|
||||
let rows = self.build_rows();
|
||||
if !rows.is_empty() && rows_height > 0 {
|
||||
let estimated_rows =
|
||||
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, inner.width, 0);
|
||||
|
||||
let mut rows_start = cursor_y;
|
||||
if rows_height > estimated_rows && rows_height > 1 {
|
||||
Self::push_line(
|
||||
buf,
|
||||
inner,
|
||||
&mut cursor_y,
|
||||
inner_bottom,
|
||||
Line::from(String::new()),
|
||||
);
|
||||
rows_start = cursor_y;
|
||||
rows_height = rows_height.saturating_sub(1);
|
||||
}
|
||||
|
||||
if rows_height > 0 {
|
||||
let rows_area = Rect {
|
||||
x: inner.x,
|
||||
y: rows_start,
|
||||
width: inner.width,
|
||||
height: rows_height,
|
||||
};
|
||||
render_rows(
|
||||
rows_area,
|
||||
buf,
|
||||
&rows,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
"no matches",
|
||||
false,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hint) = &self.footer_hint {
|
||||
let footer_area = Rect {
|
||||
x: area.x,
|
||||
y: area.y + area.height - 1,
|
||||
width: area.width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
|
||||
if inner.height > 0 && inner_bottom > 0 {
|
||||
let footer_y = inner_bottom.saturating_sub(1);
|
||||
Paragraph::new(hint.clone().dim()).render(
|
||||
Rect {
|
||||
x: inner.x,
|
||||
y: footer_y,
|
||||
width: inner.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,6 +615,6 @@ mod tests {
|
||||
view.set_search_query("filters".to_string());
|
||||
|
||||
let lines = render_lines(&view);
|
||||
assert!(lines.contains("▌ filters"));
|
||||
assert!(lines.contains("filters"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,12 @@ impl ScrollState {
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if self.scroll_top >= len {
|
||||
let clamp = visible_rows.min(len);
|
||||
self.scroll_top = len.saturating_sub(clamp);
|
||||
}
|
||||
|
||||
if let Some(sel) = self.selected_idx {
|
||||
if sel < self.scroll_top {
|
||||
self.scroll_top = sel;
|
||||
@@ -79,7 +85,7 @@ impl ScrollState {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.scroll_top = 0;
|
||||
self.selected_idx = Some(self.scroll_top.min(len - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
|
||||
use super::scroll_state::ScrollState;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
|
||||
/// A generic representation of a display row for selection popups.
|
||||
pub(crate) struct GenericDisplayRow {
|
||||
@@ -118,13 +120,13 @@ pub(crate) fn render_rows(
|
||||
max_results: usize,
|
||||
empty_message: &str,
|
||||
include_border: bool,
|
||||
prefix_cols: u16,
|
||||
) {
|
||||
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)
|
||||
@@ -132,9 +134,6 @@ pub(crate) fn render_rows(
|
||||
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,
|
||||
@@ -142,11 +141,13 @@ pub(crate) fn render_rows(
|
||||
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);
|
||||
let padding_cols = prefix_cols.saturating_sub(if include_border { 1 } else { 0 });
|
||||
if padding_cols > 0 {
|
||||
let pad_start = area.x.saturating_add(1);
|
||||
let pad_start = if include_border {
|
||||
area.x.saturating_add(1)
|
||||
} else {
|
||||
area.x
|
||||
};
|
||||
let pad_end = pad_start
|
||||
.saturating_add(padding_cols)
|
||||
.min(area.x.saturating_add(area.width));
|
||||
@@ -160,45 +161,89 @@ pub(crate) fn render_rows(
|
||||
}
|
||||
}
|
||||
|
||||
if rows_all.is_empty() {
|
||||
if content_area.height > 0 {
|
||||
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
|
||||
para.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: content_area.y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
if content_area.width == 0 || content_area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine which logical rows (items) are visible given the selection and
|
||||
// the max_results clamp. Scrolling is still item-based for simplicity.
|
||||
let max_rows_from_area = content_area.height as usize;
|
||||
let visible_items = max_results
|
||||
.min(rows_all.len())
|
||||
.min(max_rows_from_area.max(1));
|
||||
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
start_idx = sel;
|
||||
} else if visible_items > 0 {
|
||||
let bottom = start_idx + visible_items - 1;
|
||||
if sel > bottom {
|
||||
start_idx = sel + 1 - visible_items;
|
||||
}
|
||||
}
|
||||
if rows_all.is_empty() {
|
||||
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
|
||||
para.render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: content_area.y,
|
||||
width: content_area.width,
|
||||
height: 1,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
|
||||
let max_rows_from_area = content_area.height as usize;
|
||||
let max_items = max_results.min(rows_all.len());
|
||||
|
||||
let sel = state
|
||||
.selected_idx
|
||||
.unwrap_or(0)
|
||||
.min(rows_all.len().saturating_sub(1));
|
||||
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if start_idx > sel {
|
||||
start_idx = sel;
|
||||
}
|
||||
|
||||
let (visible_items, desc_col) = loop {
|
||||
let candidate_count = max_items
|
||||
.min(rows_all.len().saturating_sub(start_idx))
|
||||
.max(1);
|
||||
|
||||
let desc_col_candidate =
|
||||
compute_desc_col(rows_all, start_idx, candidate_count, content_area.width);
|
||||
|
||||
let mut used_lines = 0usize;
|
||||
let mut temp_visible = 0usize;
|
||||
for idx in start_idx..(start_idx + candidate_count) {
|
||||
let full_line = build_full_line(&rows_all[idx], desc_col_candidate);
|
||||
let options = RtOptions::new(content_area.width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col_candidate)));
|
||||
let line_count = word_wrap_line(&full_line, options).len();
|
||||
|
||||
if temp_visible > 0 && used_lines + line_count > max_rows_from_area {
|
||||
break;
|
||||
}
|
||||
|
||||
if used_lines + line_count > max_rows_from_area && temp_visible == 0 {
|
||||
temp_visible = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
used_lines = used_lines.saturating_add(line_count);
|
||||
temp_visible += 1;
|
||||
|
||||
if used_lines >= max_rows_from_area {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if temp_visible == 0 {
|
||||
temp_visible = 1;
|
||||
}
|
||||
|
||||
let end_idx = start_idx + temp_visible - 1;
|
||||
if sel <= end_idx || start_idx == sel {
|
||||
let desc = compute_desc_col(rows_all, start_idx, temp_visible, content_area.width);
|
||||
break (temp_visible, desc);
|
||||
}
|
||||
|
||||
if start_idx >= rows_all.len().saturating_sub(1) {
|
||||
let desc = compute_desc_col(rows_all, start_idx, temp_visible, content_area.width);
|
||||
break (temp_visible, desc);
|
||||
}
|
||||
|
||||
start_idx += 1;
|
||||
};
|
||||
|
||||
// Render items, wrapping descriptions and aligning wrapped lines under the
|
||||
// shared description column. Stop when we run out of vertical space.
|
||||
let mut cur_y = content_area.y;
|
||||
for (i, row) in rows_all
|
||||
.iter()
|
||||
@@ -210,44 +255,24 @@ pub(crate) fn render_rows(
|
||||
break;
|
||||
}
|
||||
|
||||
let GenericDisplayRow {
|
||||
name,
|
||||
match_indices,
|
||||
is_current: _is_current,
|
||||
description,
|
||||
} = row;
|
||||
|
||||
let full_line = build_full_line(
|
||||
&GenericDisplayRow {
|
||||
name: name.clone(),
|
||||
match_indices: match_indices.clone(),
|
||||
is_current: *_is_current,
|
||||
description: description.clone(),
|
||||
},
|
||||
desc_col,
|
||||
);
|
||||
|
||||
// Wrap with subsequent indent aligned to the description column.
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
let full_line = build_full_line(row, desc_col);
|
||||
let options = RtOptions::new(content_area.width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" ".repeat(desc_col)));
|
||||
let wrapped = word_wrap_line(&full_line, options);
|
||||
|
||||
// Render the wrapped lines.
|
||||
for mut line in wrapped {
|
||||
if cur_y >= content_area.y + content_area.height {
|
||||
break;
|
||||
}
|
||||
if Some(i) == state.selected_idx {
|
||||
// Match previous behavior: cyan + bold for the selected row.
|
||||
line.style = Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
} else if row.is_current {
|
||||
line.style = Style::default().add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
let para = Paragraph::new(line);
|
||||
para.render(
|
||||
Paragraph::new(line).render(
|
||||
Rect {
|
||||
x: content_area.x,
|
||||
y: cur_y,
|
||||
@@ -260,7 +285,6 @@ pub(crate) fn render_rows(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the number of terminal rows required to render up to `max_results`
|
||||
/// items from `rows_all` given the current scroll/selection state and the
|
||||
/// available `width`. Accounts for description wrapping and alignment so the
|
||||
@@ -270,14 +294,15 @@ pub(crate) fn measure_rows_height(
|
||||
state: &ScrollState,
|
||||
max_results: usize,
|
||||
width: u16,
|
||||
prefix_cols: u16,
|
||||
) -> u16 {
|
||||
if rows_all.is_empty() {
|
||||
return 1; // placeholder "no matches" line
|
||||
return 1;
|
||||
}
|
||||
|
||||
let content_width = width.saturating_sub(1).max(1);
|
||||
|
||||
let content_width = width.saturating_sub(prefix_cols).max(1);
|
||||
let visible_items = max_results.min(rows_all.len());
|
||||
|
||||
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
|
||||
if let Some(sel) = state.selected_idx {
|
||||
if sel < start_idx {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
assertion_line: 581
|
||||
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 │
|
||||
╰──────────────────────────────────────────────╯
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/list_selection_view.rs
|
||||
assertion_line: 572
|
||||
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 │
|
||||
╰──────────────────────────────────────────────╯
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1200
|
||||
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 "
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ this is a test reason such as one that would be produced by the model │"
|
||||
"│ │"
|
||||
"│ $ echo hello world │"
|
||||
"│ │"
|
||||
"│ Would you like to run the following 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 continue │"
|
||||
"╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
" "
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1227
|
||||
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 "
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ $ echo hello world │"
|
||||
"│ │"
|
||||
"│ Would you like to run the following 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 continue │"
|
||||
"╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
" "
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1262
|
||||
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 "
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ The model wants to apply changes │"
|
||||
"│ │"
|
||||
"│ Grant write access to /tmp for the remainder of this session. │"
|
||||
"│ │"
|
||||
"│ Would you like to apply these changes? │"
|
||||
"│ │"
|
||||
"│ › 1. Approve (Y) Apply the proposed changes │"
|
||||
"│ 2. Cancel (N) Do not apply the changes │"
|
||||
"│ │"
|
||||
"│ Press Enter to continue │"
|
||||
"╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
" "
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1429
|
||||
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 "
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ this is a test reason such as one that would be produced by the model │"
|
||||
"│ │"
|
||||
"│ $ echo 'hello world' │"
|
||||
"│ │"
|
||||
"│ Would you like to run the following 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 continue │"
|
||||
"╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
" "
|
||||
|
||||
@@ -933,18 +933,31 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
|
||||
let area = Rect::new(0, 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
(chat).render_ref(area, &mut buf);
|
||||
let mut row = String::new();
|
||||
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
|
||||
let y = 1u16.min(height.saturating_sub(1));
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
row.push(' ');
|
||||
} else {
|
||||
row.push_str(s);
|
||||
|
||||
for y in 0..area.height {
|
||||
let mut row = String::new();
|
||||
for x in 0..area.width {
|
||||
let s = buf[(x, y)].symbol();
|
||||
if s.is_empty() {
|
||||
row.push(' ');
|
||||
} else {
|
||||
row.push_str(s);
|
||||
}
|
||||
}
|
||||
if row.chars().any(|c| {
|
||||
!c.is_whitespace()
|
||||
&& c != '╭'
|
||||
&& c != '╮'
|
||||
&& c != '╯'
|
||||
&& c != '╰'
|
||||
&& c != '─'
|
||||
&& c != '│'
|
||||
}) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
row
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1764,14 +1777,14 @@ fn apply_patch_untrusted_shows_approval_modal() {
|
||||
for x in 0..area.width {
|
||||
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
|
||||
}
|
||||
if row.contains("Apply changes?") {
|
||||
if row.contains("Would you like to apply these changes?") {
|
||||
contains_title = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
contains_title,
|
||||
"expected approval modal to be visible with title 'Apply changes?'"
|
||||
"expected approval modal to be visible with title 'Would you like to apply these changes?'"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
82
codex-rs/tui/src/render/border.rs
Normal file
82
codex-rs/tui/src/render/border.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
|
||||
/// Draw the standard Codex rounded border into `buf` and return the interior
|
||||
/// rectangle where content should render. The border mirrors the appearance of
|
||||
/// `history_cell::with_border`, including one column of padding on each side.
|
||||
pub(crate) fn draw_history_border(buf: &mut Buffer, area: Rect) -> Option<Rect> {
|
||||
if area.width < 4 || area.height < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dim_style = Style::default().add_modifier(Modifier::DIM);
|
||||
|
||||
let left = area.x;
|
||||
let right = area.x + area.width - 1;
|
||||
let top = area.y;
|
||||
let bottom = area.y + area.height - 1;
|
||||
|
||||
if let Some(cell) = buf.cell_mut((left, top)) {
|
||||
cell.set_symbol("╭");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
for x in left + 1..right {
|
||||
if let Some(cell) = buf.cell_mut((x, top)) {
|
||||
cell.set_symbol("─");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((right, top)) {
|
||||
cell.set_symbol("╮");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
|
||||
if let Some(cell) = buf.cell_mut((left, bottom)) {
|
||||
cell.set_symbol("╰");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
for x in left + 1..right {
|
||||
if let Some(cell) = buf.cell_mut((x, bottom)) {
|
||||
cell.set_symbol("─");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((right, bottom)) {
|
||||
cell.set_symbol("╯");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
|
||||
for y in top + 1..bottom {
|
||||
if let Some(cell) = buf.cell_mut((left, y)) {
|
||||
cell.set_symbol("│");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((left + 1, y)) {
|
||||
cell.set_symbol(" ");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
for x in left + 2..right - 1 {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
cell.set_symbol(" ");
|
||||
cell.set_style(Style::default());
|
||||
}
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((right - 1, y)) {
|
||||
cell.set_symbol(" ");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
if let Some(cell) = buf.cell_mut((right, y)) {
|
||||
cell.set_symbol("│");
|
||||
cell.set_style(dim_style);
|
||||
}
|
||||
}
|
||||
|
||||
Some(Rect {
|
||||
x: area.x + 2,
|
||||
y: area.y + 1,
|
||||
width: area.width.saturating_sub(4),
|
||||
height: area.height.saturating_sub(2),
|
||||
})
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod border;
|
||||
pub mod highlight;
|
||||
pub mod line_utils;
|
||||
|
||||
Reference in New Issue
Block a user