Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Ibrahim
8ecb4522dc new menus 2025-09-30 15:19:41 -07:00
12 changed files with 460 additions and 264 deletions

View File

@@ -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
@@ -112,7 +118,7 @@ impl ApprovalOverlay {
let params = SelectionViewParams {
title,
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
footer_hint: Some("Press Enter to continue".to_string()),
items,
header: state.header.clone(),
..Default::default()
@@ -281,9 +287,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 +534,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:?}"
);
}

View File

@@ -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)")
@@ -232,19 +225,18 @@ impl ListSelectionView {
self.last_selected_actual_idx.take()
}
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
fn header_lines(&self, width: u16) -> Vec<Line<'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 available = width.max(1) as usize;
let mut lines = Vec::new();
for entry in &self.header {
match entry {
HeaderLine::Spacer => lines.push(Vec::new()),
HeaderLine::Spacer => lines.push(Line::from(String::new())),
HeaderLine::Text { text, italic } => {
if text.is_empty() {
lines.push(Vec::new());
lines.push(Line::from(String::new()));
continue;
}
for part in wrap(text, available) {
@@ -253,7 +245,21 @@ impl ListSelectionView {
} else {
Span::from(part.into_owned())
};
lines.push(vec![span]);
lines.push(Line::from(vec![span]));
}
}
HeaderLine::Command { command } => {
if command.is_empty() {
lines.push(Line::from(String::new()));
continue;
}
let command_width = available.saturating_sub(2).max(1);
for (idx, part) in wrap(command, command_width).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(Line::from(spans));
}
}
}
@@ -262,7 +268,7 @@ impl ListSelectionView {
}
fn header_height(&self, width: u16) -> u16 {
self.header_spans_for_width(width).len() as u16
self.header_lines(width).len() as u16
}
}
@@ -318,80 +324,83 @@ 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 rows = self.build_rows();
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);
if width < 4 {
return 3;
}
let inner_width = width.saturating_sub(4).max(1);
let mut height: u16 = 2; // border rows
height = height.saturating_add(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);
}
let rows = self.build_rows();
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, inner_width);
if !rows.is_empty() {
height = height.saturating_add(1); // spacer before rows
}
height = height.saturating_add(rows_height);
if self.footer_hint.is_some() {
height = height.saturating_add(2);
height = height.saturating_add(1);
}
height
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
if area.width < 4 || area.height < 3 {
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 {
let Some(inner) = draw_history_border(buf, area) else {
return;
};
if inner.width == 0 || inner.height == 0 {
return;
}
let mut cursor_y = inner.y;
let inner_bottom = inner.y.saturating_add(inner.height);
for line in self.header_lines(inner.width) {
if cursor_y >= inner_bottom {
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);
Paragraph::new(line).render(
Rect {
x: inner.x,
y: cursor_y,
width: inner.width,
height: 1,
},
buf,
);
cursor_y = cursor_y.saturating_add(1);
}
if next_y >= area.y + area.height {
if cursor_y >= inner_bottom {
return;
}
let title_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![
Self::dim_prefix_span(),
self.title.clone().bold(),
]))
.render(title_area, buf);
next_y = next_y.saturating_add(1);
if self.is_searchable && next_y < area.y + area.height {
let search_area = Rect {
x: area.x,
y: next_y,
width: area.width,
Paragraph::new(Line::from(self.title.clone().bold())).render(
Rect {
x: inner.x,
y: cursor_y,
width: inner.width,
height: 1,
};
},
buf,
);
cursor_y = cursor_y.saturating_add(1);
if self.is_searchable {
if cursor_y >= inner_bottom {
return;
}
let query_span: Span<'static> = if self.search_query.is_empty() {
self.search_placeholder
.as_ref()
@@ -400,54 +409,63 @@ impl BottomPaneView for ListSelectionView {
} else {
self.search_query.clone().into()
};
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
.render(search_area, buf);
next_y = next_y.saturating_add(1);
Paragraph::new(Line::from(vec![query_span])).render(
Rect {
x: inner.x,
y: cursor_y,
width: inner.width,
height: 1,
},
buf,
);
cursor_y = cursor_y.saturating_add(1);
}
if let Some(sub) = &self.subtitle {
if next_y >= area.y + area.height {
if cursor_y >= inner_bottom {
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);
Paragraph::new(Line::from(sub.clone().dim())).render(
Rect {
x: inner.x,
y: cursor_y,
width: inner.width,
height: 1,
},
buf,
);
cursor_y = cursor_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 {
let footer_reserved = self.footer_hint.is_some() as usize;
let available_rows = inner_bottom.saturating_sub(cursor_y) as usize;
let mut row_space = available_rows.saturating_sub(footer_reserved);
if !rows.is_empty() && row_space > 0 {
if cursor_y >= inner_bottom {
return;
}
Paragraph::new(Line::from(String::new())).render(
Rect {
x: inner.x,
y: cursor_y,
width: inner.width,
height: 1,
},
buf,
);
cursor_y = cursor_y.saturating_add(1);
row_space = row_space.saturating_sub(1);
}
if row_space > 0 {
let rows_area = Rect {
x: inner.x,
y: cursor_y,
width: inner.width,
height: row_space as u16,
};
render_rows(
rows_area,
buf,
@@ -455,18 +473,22 @@ impl BottomPaneView for ListSelectionView {
&self.state,
MAX_POPUP_ROWS,
"no matches",
true,
false,
);
}
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 {
Paragraph::new(hint.clone().dim()).render(
Rect {
x: inner.x,
y: inner.y + inner.height - 1,
width: inner.width,
height: 1,
},
buf,
);
}
}
}
}
@@ -579,6 +601,9 @@ mod tests {
view.set_search_query("filters".to_string());
let lines = render_lines(&view);
assert!(lines.contains("▌ filters"));
assert!(
lines.lines().any(|line| line.contains("filters")),
"expected search query to render, got {lines}"
);
}
}

View File

@@ -12,13 +12,18 @@ 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::render::line_utils::push_owned_lines;
use crate::ui_consts::LIVE_PREFIX_COLS;
/// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow {
pub name: String,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
#[allow(dead_code)]
pub is_current: bool,
pub description: Option<String>, // optional grey text after the name
}
@@ -108,6 +113,25 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
Line::from(full_spans)
}
fn wrap_options(desc_col: usize, width: u16) -> RtOptions<'static> {
RtOptions::new(width as usize)
.initial_indent(Line::from(String::new()))
.subsequent_indent(Line::from(" ".repeat(desc_col)))
}
fn wrap_row(row: &GenericDisplayRow, desc_col: usize, width: u16) -> Vec<Line<'static>> {
let full_line = build_full_line(row, desc_col);
let wrapped = word_wrap_line(&full_line, wrap_options(desc_col, width));
let mut owned = Vec::with_capacity(wrapped.len());
push_owned_lines(&wrapped, &mut owned);
owned
}
fn wrapped_line_count(row: &GenericDisplayRow, desc_col: usize, width: u16) -> usize {
let full_line = build_full_line(row, desc_col);
word_wrap_line(&full_line, wrap_options(desc_col, width)).len()
}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
pub(crate) fn render_rows(
@@ -161,9 +185,8 @@ 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(
if content_area.height > 0 && content_area.width > 0 {
Paragraph::new(Line::from(empty_message.dim().italic())).render(
Rect {
x: content_area.x,
y: content_area.y,
@@ -176,78 +199,134 @@ pub(crate) fn render_rows(
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));
if content_area.width == 0 || content_area.height == 0 {
return;
}
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
let total_items = rows_all.len();
let height_limit = content_area.height as usize;
let max_visible_items = max_results.min(total_items).min(height_limit.max(1));
if max_visible_items == 0 {
return;
}
let mut start_idx = state.scroll_top.min(total_items.saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
if start_idx > sel {
start_idx = sel;
} else if visible_items > 0 {
let bottom = start_idx + visible_items - 1;
if sel > bottom {
start_idx = sel + 1 - visible_items;
}
}
}
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
let mut attempts = 0usize;
let mut chosen_start = start_idx;
let mut chosen_visible = max_visible_items;
let mut chosen_desc_col =
compute_desc_col(rows_all, start_idx, chosen_visible, content_area.width);
// Render items, wrapping descriptions and aligning wrapped lines under the
// shared description column. Stop when we run out of vertical space.
loop {
attempts = attempts.saturating_add(1);
if attempts > total_items {
break;
}
let remaining = total_items - start_idx;
if remaining == 0 {
break;
}
let window_len = max_visible_items.min(remaining);
if window_len == 0 {
break;
}
let mut desc_col = compute_desc_col(rows_all, start_idx, window_len, content_area.width);
let mut used_height = 0usize;
let mut actual_count = 0usize;
for row in rows_all.iter().skip(start_idx).take(window_len) {
let line_count = wrapped_line_count(row, desc_col, content_area.width);
if line_count == 0 {
continue;
}
if used_height + line_count > height_limit {
break;
}
used_height += line_count;
actual_count += 1;
}
if actual_count == 0 {
actual_count = 1.min(window_len);
}
desc_col = compute_desc_col(rows_all, start_idx, actual_count, content_area.width);
let mut refined_height = 0usize;
let mut refined_count = 0usize;
for row in rows_all.iter().skip(start_idx).take(actual_count) {
let line_count = wrapped_line_count(row, desc_col, content_area.width);
if line_count == 0 {
continue;
}
if refined_height + line_count > height_limit {
break;
}
refined_height += line_count;
refined_count += 1;
}
if refined_count == 0 {
refined_count = 1.min(window_len);
}
chosen_start = start_idx;
chosen_visible = refined_count;
chosen_desc_col = desc_col;
let selection_visible = state.selected_idx.map_or(true, |sel| {
sel >= start_idx && sel < start_idx + refined_count
});
if selection_visible {
break;
}
if let Some(sel) = state.selected_idx {
if sel >= start_idx + refined_count {
if start_idx + 1 >= total_items {
break;
}
start_idx += 1;
continue;
}
if sel < start_idx {
if start_idx == 0 {
break;
}
start_idx -= 1;
continue;
}
}
break;
}
let content_bottom = content_area.y.saturating_add(content_area.height);
let mut cur_y = content_area.y;
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
.skip(chosen_start)
.take(chosen_visible)
{
if cur_y >= content_area.y + content_area.height {
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 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;
for mut line in wrap_row(row, chosen_desc_col, content_area.width) {
if cur_y >= content_bottom {
return;
}
if Some(i) == state.selected_idx {
// Match previous behavior: cyan + bold for the selected row.
line.style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
}
let para = Paragraph::new(line);
para.render(
Paragraph::new(line).render(
Rect {
x: content_area.x,
y: cur_y,
@@ -292,21 +371,10 @@ pub(crate) fn measure_rows_height(
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_width);
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let mut total: u16 = 0;
for row in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
.map(|(_, r)| r)
{
let full_line = build_full_line(row, desc_col);
let opts = RtOptions::new(content_width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(desc_col)));
total = total.saturating_add(word_wrap_line(&full_line, opts).len() as u16);
for row in rows_all.iter().skip(start_idx).take(visible_items) {
let lines = wrapped_line_count(row, desc_col, content_width) as u16;
total = total.saturating_add(lines.max(1));
}
total.max(1)
}

View File

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

View File

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

View File

@@ -1,18 +1,20 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1208
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 "
"╰──────────────────────────────────────────────────────────────────────────────╯"
" "

View File

@@ -1,16 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1235
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 "
"╰──────────────────────────────────────────────────────────────────────────────╯"
" "

View File

@@ -1,16 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1270
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 "
"╰──────────────────────────────────────────────────────────────────────────────╯"
" "

View File

@@ -1,18 +1,20 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1437
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 "
"╰──────────────────────────────────────────────────────────────────────────────╯"
" "

View File

@@ -933,18 +933,42 @@ 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 let (Some(start), Some(end)) = (row.find('│'), row.rfind('│')) {
let left = start + '│'.len_utf8();
if end > left {
if let Some(content) = row.get(left..end) {
let trimmed = content.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
continue;
}
let trimmed = row.trim();
if trimmed.is_empty() {
continue;
}
let is_border = trimmed
.chars()
.all(|c| matches!(c, '╭' | '╮' | '╰' | '╯' | '─'));
if !is_border {
return trimmed.to_string();
}
}
row
String::new()
}
#[test]
@@ -1764,14 +1788,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?'"
);
}

View File

@@ -0,0 +1,57 @@
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 available for content. When the area is too small to hold the
/// border (width < 4 or height < 3) this returns `None` and leaves the buffer
/// untouched.
pub(crate) fn draw_history_border(buf: &mut Buffer, area: Rect) -> Option<Rect> {
if area.width < 4 || area.height < 3 {
return None;
}
let 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;
// Top border
buf[(left, top)].set_symbol("").set_style(style);
for x in left + 1..right {
buf[(x, top)].set_symbol("").set_style(style);
}
buf[(right, top)].set_symbol("").set_style(style);
// Bottom border
buf[(left, bottom)].set_symbol("").set_style(style);
for x in left + 1..right {
buf[(x, bottom)].set_symbol("").set_style(style);
}
buf[(right, bottom)].set_symbol("").set_style(style);
// Sides + clear interior padding columns
for y in top + 1..bottom {
buf[(left, y)].set_symbol("").set_style(style);
buf[(right, y)].set_symbol("").set_style(style);
// Left padding column
buf[(left + 1, y)].set_symbol(" ").set_style(style);
// Right padding column
buf[(right - 1, y)].set_symbol(" ").set_style(style);
// Interior content area reset to spaces
for x in left + 2..right - 1 {
buf[(x, y)].set_symbol(" ").set_style(Style::default());
}
}
Some(Rect {
x: area.x + 2,
y: area.y + 1,
width: area.width - 4,
height: area.height - 2,
})
}

View File

@@ -1,2 +1,3 @@
pub mod border;
pub mod highlight;
pub mod line_utils;