diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 7ab2b79a57..ea2c2d28f5 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -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 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:?}" ); } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index b20d7476a7..bd2a6be7e1 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -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; #[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>> { + fn header_lines(&self, width: u16) -> Vec> { 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> = 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}" + ); } } diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index f3b238016a..c915682beb 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -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>, // indices to bold (char positions) + #[allow(dead_code)] pub is_current: bool, pub description: Option, // 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> { + 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) } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap index ced53b7ce6..0a7694e8bd 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_with_subtitle.snap @@ -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 │ +╰──────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap index b9858a4307..716549c98d 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__list_selection_view__tests__list_selection_spacing_without_subtitle.snap @@ -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 │ +╰──────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index 304adf547e..ce230a6b2e 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -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 │" +"╰──────────────────────────────────────────────────────────────────────────────╯" " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 239681acb0..034c28afd7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -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 │" +"╰──────────────────────────────────────────────────────────────────────────────╯" " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap index 2ee14a57f2..811131b765 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_patch.snap @@ -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 │" +"╰──────────────────────────────────────────────────────────────────────────────╯" " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index e762896fd7..be4b4fb151 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -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 │" +"╰──────────────────────────────────────────────────────────────────────────────╯" " " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f3799ad336..09444c08b7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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?'" ); } diff --git a/codex-rs/tui/src/render/border.rs b/codex-rs/tui/src/render/border.rs new file mode 100644 index 0000000000..80080066a4 --- /dev/null +++ b/codex-rs/tui/src/render/border.rs @@ -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 { + 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, + }) +} diff --git a/codex-rs/tui/src/render/mod.rs b/codex-rs/tui/src/render/mod.rs index e457423f4e..78c0b4fab0 100644 --- a/codex-rs/tui/src/render/mod.rs +++ b/codex-rs/tui/src/render/mod.rs @@ -1,2 +1,3 @@ +pub mod border; pub mod highlight; pub mod line_utils;